diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index e7616df351..172ea63da2 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -2843,6 +2843,7 @@ dependencies = [ "argon2", "base64", "bip39", + "cfg-if", "coconut-interface", "config", "cosmrs", diff --git a/nym-wallet/src-tauri/Cargo.toml b/nym-wallet/src-tauri/Cargo.toml index e2118fb646..9b2dd4a8a6 100644 --- a/nym-wallet/src-tauri/Cargo.toml +++ b/nym-wallet/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-macros = "=1.0.0-rc.1" [dependencies] bip39 = "1.0" +cfg-if = "1.0.0" dirs = "4.0" eyre = "0.6.5" futures = "0.3.15" diff --git a/nym-wallet/src-tauri/src/error.rs b/nym-wallet/src-tauri/src/error.rs index 0f3aa9bd10..077e949939 100644 --- a/nym-wallet/src-tauri/src/error.rs +++ b/nym-wallet/src-tauri/src/error.rs @@ -77,10 +77,18 @@ pub enum BackendError { NetworkNotSupported(config::defaults::all::Network), #[error("Could not access the local data storage directory")] UnknownStorageDirectory, - #[error("No nymd validator configured")] - NoNymdValidatorConfigured, #[error("No validator API URL configured")] NoValidatorApiUrlConfigured, + #[error("The wallet file already exists")] + WalletFileAlreadyExists, + #[error("The wallet file is not found")] + WalletFileNotFound, + #[error("Account ID not found in wallet")] + NoSuchIdInWallet, + #[error("Account ID already found in wallet")] + IdAlreadyExistsInWallet, + #[error("Adding a different password to the wallet not currently supported")] + WalletDifferentPasswordDetected, } impl Serialize for BackendError { diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 61d78ed3de..316db50ac7 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -15,6 +15,7 @@ mod error; mod menu; mod network; mod operations; +mod platform_constants; mod state; mod utils; // temporarily until it is actually used @@ -37,8 +38,11 @@ fn main() { mixnet::account::connect_with_mnemonic, mixnet::account::create_new_account, mixnet::account::create_new_mnemonic, + mixnet::account::create_password, + mixnet::account::does_password_file_exist, mixnet::account::get_balance, mixnet::account::logout, + mixnet::account::sign_in_with_password, mixnet::account::switch_network, mixnet::account::update_validator_urls, mixnet::admin::get_contract_settings, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/account.rs b/nym-wallet/src-tauri/src/operations/mixnet/account.rs index e369f9c976..02b632466a 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/account.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/account.rs @@ -4,8 +4,11 @@ use crate::error::BackendError; use crate::network::Network; use crate::nymd_client; use crate::state::State; +use crate::wallet_storage::{self, DEFAULT_WALLET_ACCOUNT_ID}; use bip39::{Language, Mnemonic}; +use config::defaults::COSMOS_DERIVATION_PATH; +use cosmrs::bip32::DerivationPath; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::convert::TryInto; @@ -227,9 +230,10 @@ async fn choose_validators( .next() // We always have at least one hardcoded default validator .unwrap(); - println!( + log::info!( "Using default for {network}: {}, {}", - default_validator.nymd_url, default_validator.api_url, + default_validator.nymd_url, + default_validator.api_url, ); default_validator }); @@ -305,9 +309,10 @@ async fn try_connect_to_validator( )?; if is_validator_connection_ok(&client).await { - println!( + log::info!( "Connection ok for {network}: {}, {}", - validator.nymd_url, validator.api_url + validator.nymd_url, + validator.api_url ); Ok(Some((network, validator.clone()))) } else { @@ -322,3 +327,45 @@ async fn is_validator_connection_ok(client: &Client) -> bool Err(_) | Ok(_) => true, } } + +#[tauri::command] +pub fn does_password_file_exist() -> Result { + log::info!("Checking wallet file"); + let file = wallet_storage::wallet_login_filepath()?; + if file.exists() { + log::info!("Exists: {}", file.to_string_lossy()); + Ok(true) + } else { + log::info!("Does not exist: {}", file.to_string_lossy()); + Ok(false) + } +} + +#[tauri::command] +pub fn create_password(mnemonic: String, password: String) -> Result<(), BackendError> { + if does_password_file_exist()? { + return Err(BackendError::WalletFileAlreadyExists); + } + log::info!("Creating password"); + + let mnemonic = Mnemonic::from_str(&mnemonic)?; + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + // Currently we only support a single, default, id in the wallet + let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let password = wallet_storage::UserPassword::new(password); + wallet_storage::store_wallet_login_information(mnemonic, hd_path, id, &password) +} + +#[tauri::command] +pub async fn sign_in_with_password( + password: String, + state: tauri::State<'_, Arc>>, +) -> Result { + log::info!("Signing in with password"); + + // Currently we only support a single, default, id in the wallet + let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let password = wallet_storage::UserPassword::new(password); + let stored_account = wallet_storage::load_existing_wallet_login_information(&id, &password)?; + _connect_with_mnemonic(stored_account.mnemonic().clone(), state).await +} diff --git a/nym-wallet/src-tauri/src/platform_constants.rs b/nym-wallet/src-tauri/src/platform_constants.rs new file mode 100644 index 0000000000..309a5fee7c --- /dev/null +++ b/nym-wallet/src-tauri/src/platform_constants.rs @@ -0,0 +1,22 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// Specify filenames and other platform specific constants to respect platform conventions, or at +// least, something popular on each respective platform. + +cfg_if::cfg_if! { + if #[cfg(target_os = "linux")] { + pub const STORAGE_DIR_NAME: &str = "nym-wallet"; + pub const WALLET_INFO_FILENAME: &str = "saved-wallet.json"; + } else if #[cfg(taret_os = "macos")] { + pub const STORAGE_DIR_NAME: &str = "nym-wallet"; + pub const WALLET_INFO_FILENAME: &str = "saved-wallet.json"; + } else if #[cfg(taret_os = "windows")] { + pub const STORAGE_DIR_NAME: &str = "NymWallet"; + pub const WALLET_INFO_FILENAME: &str = "saved_wallet.json"; + } else { + // This case is likely to be a unix-y system + pub const STORAGE_DIR_NAME: &str = "nym-wallet"; + pub const WALLET_INFO_FILENAME: &str = "saved-wallet.json"; + } +} diff --git a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs index 487b029eb0..817bcc067b 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs @@ -8,6 +8,96 @@ use std::fmt::Formatter; use zeroize::Zeroize; use zeroize::Zeroizing; +use crate::error::BackendError; + +use super::encryption::EncryptedData; +use super::password::WalletAccountId; +use super::UserPassword; + +const CURRENT_WALLET_FILE_VERSION: u32 = 1; + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct StoredWallet { + version: u32, + accounts: Vec, +} + +impl StoredWallet { + pub fn version(&self) -> u32 { + self.version + } + + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub fn len(&self) -> usize { + self.accounts.len() + } + + pub fn encrypted_account_by_index(&self, index: usize) -> Option<&EncryptedAccount> { + self.accounts.get(index) + } + + fn encrypted_account( + &self, + id: &WalletAccountId, + ) -> Result<&EncryptedData, BackendError> { + self + .accounts + .iter() + .find(|account| &account.id == id) + .map(|account| &account.account) + .ok_or(BackendError::NoSuchIdInWallet) + } + + pub fn add_encrypted_account( + &mut self, + new_account: EncryptedAccount, + ) -> Result<(), BackendError> { + if let Ok(_) = self.encrypted_account(&new_account.id) { + return Err(BackendError::IdAlreadyExistsInWallet); + } + self.accounts.push(new_account); + Ok(()) + } + + pub fn decrypt_account( + &self, + id: &WalletAccountId, + password: &UserPassword, + ) -> Result { + self.encrypted_account(id)?.decrypt_struct(password) + } + + pub fn decrypt_all(&self, password: &UserPassword) -> Result, BackendError> { + self + .accounts + .iter() + .map(|account| account.account.decrypt_struct(password)) + .collect::, _>>() + } + + pub fn password_can_decrypt_all(&self, password: &UserPassword) -> bool { + self.decrypt_all(password).is_ok() + } +} + +impl Default for StoredWallet { + fn default() -> Self { + StoredWallet { + version: CURRENT_WALLET_FILE_VERSION, + accounts: Vec::new(), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct EncryptedAccount { + pub id: WalletAccountId, + pub account: EncryptedData, +} + // future-proofing #[derive(Serialize, Deserialize, Debug, Zeroize)] #[serde(untagged)] @@ -24,6 +114,14 @@ impl StoredAccount { ) -> StoredAccount { StoredAccount::Mnemonic(MnemonicAccount { mnemonic, hd_path }) } + + // If we add accounts backed by something that is not a mnemonic, this should probably be changed + // to return `Option<..>`. + pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { + match self { + StoredAccount::Mnemonic(account) => account.mnemonic(), + } + } } #[derive(Serialize, Deserialize, Debug)] @@ -33,8 +131,6 @@ pub(crate) struct MnemonicAccount { hd_path: DerivationPath, } -// we only ever want to expose those getters in the test code -#[cfg(test)] impl MnemonicAccount { pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { &self.mnemonic diff --git a/nym-wallet/src-tauri/src/wallet_storage/encryption.rs b/nym-wallet/src-tauri/src/wallet_storage/encryption.rs index af30b350af..a8aa27fe84 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/encryption.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/encryption.rs @@ -42,7 +42,7 @@ pub(crate) struct EncryptedData { impl Drop for EncryptedData { fn drop(&mut self) { - self.zeroize() + self.zeroize(); } } diff --git a/nym-wallet/src-tauri/src/wallet_storage/mod.rs b/nym-wallet/src-tauri/src/wallet_storage/mod.rs index de5c95c1e0..1ac9c14084 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/mod.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/mod.rs @@ -1,19 +1,26 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +pub(crate) use crate::wallet_storage::password::{UserPassword, WalletAccountId}; + use crate::error::BackendError; +use crate::operations::mixnet::account::create_new_account; +use crate::platform_constants::{STORAGE_DIR_NAME, WALLET_INFO_FILENAME}; use crate::wallet_storage::account_data::StoredAccount; use crate::wallet_storage::encryption::{encrypt_struct, EncryptedData}; -use crate::wallet_storage::password::UserPassword; use cosmrs::bip32::DerivationPath; +use serde::{Deserialize, Serialize}; use std::fs::{create_dir_all, OpenOptions}; use std::path::PathBuf; + +use self::account_data::{EncryptedAccount, StoredWallet}; + pub(crate) mod account_data; pub(crate) mod encryption; + mod password; -const STORAGE_DIR_NAME: &str = "NymWallet"; -const WALLET_INFO_FILENAME: &str = "saved_wallet.json"; +pub(crate) const DEFAULT_WALLET_ACCOUNT_ID: &str = "default"; fn get_storage_directory() -> Result { tauri::api::path::local_data_dir() @@ -25,52 +32,76 @@ pub(crate) fn wallet_login_filepath() -> Result { get_storage_directory().map(|dir| dir.join(WALLET_INFO_FILENAME)) } -pub(crate) fn load_existing_wallet_login_information( - password: &UserPassword, -) -> Result, BackendError> { +pub(crate) fn load_existing_wallet(password: &UserPassword) -> Result { let store_dir = get_storage_directory()?; let filepath = store_dir.join(WALLET_INFO_FILENAME); + load_existing_wallet_at_file(filepath) +} - load_existing_wallet_login_information_at_file(filepath, password) +fn load_existing_wallet_at_file(filepath: PathBuf) -> Result { + if !filepath.exists() { + return Err(BackendError::WalletFileNotFound); + } + let file = OpenOptions::new().read(true).open(filepath)?; + let wallet: StoredWallet = serde_json::from_reader(file)?; + Ok(wallet) +} + +pub(crate) fn load_existing_wallet_login_information( + id: &WalletAccountId, + password: &UserPassword, +) -> Result { + let store_dir = get_storage_directory()?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + load_existing_wallet_login_information_at_file(filepath, id, password) } fn load_existing_wallet_login_information_at_file( filepath: PathBuf, + id: &WalletAccountId, password: &UserPassword, -) -> Result, BackendError> { - if !filepath.exists() { - return Ok(Vec::new()); - } - let file = OpenOptions::new().read(true).open(filepath)?; - let encrypted_data: EncryptedData> = serde_json::from_reader(file)?; - encrypted_data.decrypt_struct(password) +) -> Result { + load_existing_wallet_at_file(filepath)?.decrypt_account(id, password) } pub(crate) fn store_wallet_login_information( mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - password: UserPassword, + id: WalletAccountId, + password: &UserPassword, ) -> Result<(), BackendError> { // make sure the entire directory structure exists let store_dir = get_storage_directory()?; create_dir_all(&store_dir)?; let filepath = store_dir.join(WALLET_INFO_FILENAME); - store_wallet_login_information_at_file(filepath, mnemonic, hd_path, &password) + store_wallet_login_information_at_file(filepath, mnemonic, hd_path, id, password) } fn store_wallet_login_information_at_file( filepath: PathBuf, mnemonic: bip39::Mnemonic, hd_path: DerivationPath, + id: WalletAccountId, password: &UserPassword, ) -> Result<(), BackendError> { - let mut all_accounts = - load_existing_wallet_login_information_at_file(filepath.clone(), password)?; - let new_account = StoredAccount::new_mnemonic_backed_account(mnemonic, hd_path); - all_accounts.push(new_account); + let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { + Err(BackendError::WalletFileNotFound) => StoredWallet::default(), + result => result?, + }; - let encrypted = encrypt_struct(&all_accounts, password)?; + // Confirm that the given password also can unlock the other entries + if !stored_wallet.password_can_decrypt_all(password) { + return Err(BackendError::WalletDifferentPasswordDetected); + } + + let new_account = StoredAccount::new_mnemonic_backed_account(mnemonic, hd_path); + let new_encrypted_account = EncryptedAccount { + id, + account: encrypt_struct(&new_account, password)?, + }; + + stored_wallet.add_encrypted_account(new_encrypted_account)?; let file = OpenOptions::new() .create(true) @@ -78,9 +109,7 @@ fn store_wallet_login_information_at_file( .truncate(true) .open(filepath)?; - serde_json::to_writer_pretty(file, &encrypted)?; - - Ok(()) + Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) } // this function should probably exist, but I guess we need to discuss how it should behave in the context of the UX @@ -91,15 +120,11 @@ fn store_wallet_login_information_at_file( #[cfg(test)] mod tests { use super::*; + use crate::wallet_storage::encryption::encrypt_data; use config::defaults::COSMOS_DERIVATION_PATH; use std::path::Path; use tempfile::tempdir; - fn read_encrypted_blob(file: PathBuf) -> EncryptedData> { - let file = OpenOptions::new().read(true).open(&file).unwrap(); - serde_json::from_reader(file).unwrap() - } - // I'm not 100% sure how to feel about having to touch the file system at all #[test] fn storing_wallet_information() { @@ -114,29 +139,36 @@ mod tests { let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); - // nothing was stored on the disk, so regardless of password used, there will be no error, but - // returned list will be empty - assert!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &password) - .unwrap() - .is_empty() - ); - assert!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &bad_password) - .unwrap() - .is_empty() - ); + let id1 = WalletAccountId::new("first".to_string()); + let id2 = WalletAccountId::new("second".to_string()); - // store the first account + // Nothing was stored on the disk + assert!(matches!( + load_existing_wallet_at_file(wallet_file.clone()), + Err(BackendError::WalletFileNotFound), + )); + assert!(matches!( + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password), + Err(BackendError::WalletFileNotFound), + )); + + // Store the first account store_wallet_login_information_at_file( wallet_file.clone(), dummy_account1.clone(), cosmos_hd_path.clone(), + id1.clone(), &password, ) .unwrap(); - let encrypted_blob = read_encrypted_blob(wallet_file.clone()); + let stored_wallet = load_existing_wallet_at_file(wallet_file.clone()).unwrap(); + assert_eq!(stored_wallet.len(), 1); + assert_eq!( + stored_wallet.encrypted_account_by_index(0).unwrap().id, + WalletAccountId::new("first".to_string()) + ); + let encrypted_blob = &stored_wallet.encrypted_account_by_index(0).unwrap().account; // some actual ciphertext was saved assert!(!encrypted_blob.ciphertext().is_empty()); @@ -146,53 +178,78 @@ mod tests { let original_salt = encrypted_blob.salt().to_vec(); // trying to load it with wrong password now fails - assert!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &bad_password).is_err() - ); + assert!(matches!( + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &bad_password), + Err(BackendError::DecryptionError), + )); + // and with the wrong id also fails + assert!(matches!( + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id2, &password), + Err(BackendError::NoSuchIdInWallet), + )); - let loaded_accounts = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &password).unwrap(); - println!("{:?}", loaded_accounts); - assert_eq!(1, loaded_accounts.len()); + // and storing the same id again fails + assert!(matches!( + store_wallet_login_information_at_file( + wallet_file.clone(), + dummy_account1.clone(), + cosmos_hd_path.clone(), + id1.clone(), + &password, + ), + Err(BackendError::IdAlreadyExistsInWallet), + )); - let StoredAccount::Mnemonic(acc) = &loaded_accounts[0]; + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); + + let StoredAccount::Mnemonic(ref acc) = loaded_account; assert_eq!(&dummy_account1, acc.mnemonic()); assert_eq!(&cosmos_hd_path, acc.hd_path()); - // can't store extra account if you use different password - assert!(store_wallet_login_information_at_file( - wallet_file.clone(), - dummy_account2.clone(), - cosmos_hd_path.clone(), - &bad_password, - ) - .is_err()); + // Can't store extra account if you use different password + assert!(matches!( + store_wallet_login_information_at_file( + wallet_file.clone(), + dummy_account2.clone(), + cosmos_hd_path.clone(), + id2.clone(), + &bad_password + ), + Err(BackendError::WalletDifferentPasswordDetected), + )); // add extra account properly now store_wallet_login_information_at_file( wallet_file.clone(), dummy_account2.clone(), different_hd_path.clone(), + id2.clone(), &password, ) .unwrap(); - let encrypted_blob = read_encrypted_blob(wallet_file.clone()); + let loaded_accounts = load_existing_wallet_at_file(wallet_file.clone()).unwrap(); + assert_eq!(2, loaded_accounts.len()); + let encrypted_blob = &loaded_accounts + .encrypted_account_by_index(1) + .unwrap() + .account; // fresh IV and salt are used assert_ne!(original_iv, encrypted_blob.iv()); assert_ne!(original_salt, encrypted_blob.salt()); - let loaded_accounts = - load_existing_wallet_login_information_at_file(wallet_file, &password).unwrap(); - assert_eq!(2, loaded_accounts.len()); - // first account should be unchanged - let StoredAccount::Mnemonic(acc1) = &loaded_accounts[0]; + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); + let StoredAccount::Mnemonic(ref acc1) = loaded_account; assert_eq!(&dummy_account1, acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); - let StoredAccount::Mnemonic(acc2) = &loaded_accounts[1]; + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file, &id2, &password).unwrap(); + let StoredAccount::Mnemonic(ref acc2) = loaded_account; assert_eq!(&dummy_account2, acc2.mnemonic()); assert_eq!(&different_hd_path, acc2.hd_path()); } diff --git a/nym-wallet/src-tauri/src/wallet_storage/password.rs b/nym-wallet/src-tauri/src/wallet_storage/password.rs index 0dd2b0f67c..f81b5bf7c1 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/password.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/password.rs @@ -1,8 +1,24 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use zeroize::Zeroize; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct WalletAccountId(String); + +impl WalletAccountId { + pub(crate) fn new(id: String) -> WalletAccountId { + WalletAccountId(id) + } +} + +impl AsRef for WalletAccountId { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + // simple wrapper for String that will get zeroized on drop #[derive(Zeroize)] #[zeroize(drop)]