From a5e6032393fcf6d98f1bd0f87a93df437a937ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20H=C3=A4ggblad?= Date: Tue, 12 Apr 2022 11:53:38 +0200 Subject: [PATCH] wallet: support multiple accounts per encrypted login (#1205) * wallet: support multiple accounts per encrypted login Rework wallet storage to allow grouping accounts under a single encrypted entry, in a way that is backwards compatible. * wallet: remove commented out lines --- nym-wallet/src-tauri/src/error.rs | 8 + nym-wallet/src-tauri/src/main.rs | 7 +- .../src/operations/mixnet/account.rs | 73 +++- .../src/wallet_storage/account_data.rs | 257 ++++++++++--- .../src-tauri/src/wallet_storage/mod.rs | 341 +++++++++++++++--- .../src-tauri/src/wallet_storage/password.rs | 14 +- 6 files changed, 579 insertions(+), 121 deletions(-) diff --git a/nym-wallet/src-tauri/src/error.rs b/nym-wallet/src-tauri/src/error.rs index 077e949939..ef0da6a5f8 100644 --- a/nym-wallet/src-tauri/src/error.rs +++ b/nym-wallet/src-tauri/src/error.rs @@ -85,10 +85,18 @@ pub enum BackendError { WalletFileNotFound, #[error("Account ID not found in wallet")] NoSuchIdInWallet, + #[error("Account ID not found in wallet login entry")] + NoSuchIdInWalletLoginEntry, #[error("Account ID already found in wallet")] IdAlreadyExistsInWallet, + #[error("Account ID already found in stored wallet login")] + IdAlreadyExistsInStoredWalletLogin, #[error("Adding a different password to the wallet not currently supported")] WalletDifferentPasswordDetected, + #[error("Unexpted multiple account entries found")] + WalletUnexpectedMultipleAccounts, + #[error("Unexpted mnemonic account found")] + WalletUnexpectedMnemonicAccount, } impl Serialize for BackendError { diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 61346a19cf..417cc416e9 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -34,21 +34,24 @@ fn main() { tauri::Builder::default() .manage(Arc::new(RwLock::new(State::default()))) .invoke_handler(tauri::generate_handler![ + mixnet::account::add_account_for_password, mixnet::account::connect_with_mnemonic, - mixnet::account::validate_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::get_validator_nymd_urls, mixnet::account::get_validator_api_urls, + mixnet::account::get_validator_nymd_urls, + mixnet::account::list_accounts_for_password, mixnet::account::logout, + mixnet::account::remove_account_for_password, mixnet::account::remove_password, mixnet::account::sign_in_with_password, mixnet::account::switch_network, mixnet::account::update_validator_urls, mixnet::account::validate_mnemonic, + mixnet::account::validate_mnemonic, mixnet::admin::get_contract_settings, mixnet::admin::update_contract_settings, mixnet::bond::bond_gateway, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/account.rs b/nym-wallet/src-tauri/src/operations/mixnet/account.rs index 12901cc046..2c29d81d38 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/account.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/account.rs @@ -4,6 +4,7 @@ use crate::error::BackendError; use crate::network::Network as WalletNetwork; use crate::nymd_client; use crate::state::State; +use crate::wallet_storage::account_data::StoredLogin; use crate::wallet_storage::{self, DEFAULT_WALLET_ACCOUNT_ID}; use bip39::{Language, Mnemonic}; @@ -113,9 +114,8 @@ pub async fn create_new_account( } #[tauri::command] -pub async fn create_new_mnemonic() -> Result { - let rand_mnemonic = random_mnemonic(); - Ok(rand_mnemonic.to_string()) +pub fn create_new_mnemonic() -> String { + random_mnemonic().to_string() } #[tauri::command] @@ -394,16 +394,16 @@ pub fn does_password_file_exist() -> Result { } #[tauri::command] -pub fn create_password(mnemonic: String, password: String) -> Result<(), BackendError> { +pub fn create_password(mnemonic: &str, password: String) -> Result<(), BackendError> { if does_password_file_exist()? { return Err(BackendError::WalletFileAlreadyExists); } log::info!("Creating password"); - let mnemonic = Mnemonic::from_str(&mnemonic)?; + 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 id = wallet_storage::AccountId::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) } @@ -416,15 +416,70 @@ pub async fn sign_in_with_password( 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 id = wallet_storage::AccountId::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 + let mnemonic = match stored_account { + StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(), + StoredLogin::Multiple(ref accounts) => { + // Login using the first account in the list + accounts + .get_accounts() + .next() + .ok_or(BackendError::NoSuchIdInWalletLoginEntry)? + .account + .mnemonic() + .clone() + } + }; + _connect_with_mnemonic(mnemonic, state).await } #[tauri::command] pub fn remove_password() -> Result<(), BackendError> { log::info!("Removing password"); - let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let id = wallet_storage::AccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); wallet_storage::remove_wallet_login_information(&id) } + +#[tauri::command] +pub fn add_account_for_password( + mnemonic: &str, + password: &str, + inner_id: &str, +) -> Result<(), BackendError> { + 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::AccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let inner_id = wallet_storage::AccountId::new(inner_id.to_string()); + let password = wallet_storage::UserPassword::new(password.to_string()); + wallet_storage::append_account_to_wallet_login_information( + mnemonic, hd_path, id, inner_id, &password, + ) +} + +#[tauri::command] +pub fn remove_account_for_password(password: &str, inner_id: &str) -> Result<(), BackendError> { + // Currently we only support a single, default, id in the wallet + let id = wallet_storage::AccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let inner_id = wallet_storage::AccountId::new(inner_id.to_string()); + let password = wallet_storage::UserPassword::new(password.to_string()); + wallet_storage::remove_account_from_wallet_login(&id, &inner_id, &password) +} + +#[tauri::command] +pub fn list_accounts_for_password(password: &str) -> Result, BackendError> { + // Currently we only support a single, default, id in the wallet + let id = wallet_storage::AccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let password = wallet_storage::UserPassword::new(password.to_string()); + let login = wallet_storage::load_existing_wallet_login_information(&id, &password)?; + let ids = match login { + StoredLogin::Mnemonic(_) => vec![id.to_string()], + StoredLogin::Multiple(ref accounts) => accounts + .get_accounts() + .map(|account| account.id.to_string()) + .collect::>(), + }; + Ok(ids) +} 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 760208b6b8..7336a33c0e 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs @@ -8,15 +8,16 @@ use zeroize::Zeroize; use crate::error::BackendError; use super::encryption::EncryptedData; -use super::password::WalletAccountId; +use super::password::AccountId; use super::UserPassword; const CURRENT_WALLET_FILE_VERSION: u32 = 1; +/// The wallet, stored as a serialized json file. #[derive(Serialize, Deserialize, Debug)] pub(crate) struct StoredWallet { version: u32, - accounts: Vec, + accounts: Vec, } impl StoredWallet { @@ -25,16 +26,59 @@ impl StoredWallet { self.version } - pub fn is_empty(&self) -> bool { - self.accounts.is_empty() - } - #[allow(unused)] pub fn len(&self) -> usize { self.accounts.len() } - pub fn remove_account(&mut self, id: &WalletAccountId) -> Option { + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub fn add_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> { + if self.get_encrypted_login(&new_login.id).is_ok() { + return Err(BackendError::IdAlreadyExistsInWallet); + } + self.accounts.push(new_login); + Ok(()) + } + + fn get_encrypted_login( + &self, + id: &AccountId, + ) -> Result<&EncryptedData, BackendError> { + self + .accounts + .iter() + .find(|account| &account.id == id) + .map(|account| &account.account) + .ok_or(BackendError::NoSuchIdInWallet) + } + + fn get_encrypted_login_mut( + &mut self, + id: &AccountId, + ) -> Result<&mut EncryptedLogin, BackendError> { + self + .accounts + .iter_mut() + .find(|account| &account.id == id) + //.map(|account| &mut account.account) + .ok_or(BackendError::NoSuchIdInWallet) + } + + #[cfg(test)] + pub fn get_encrypted_login_by_index(&self, index: usize) -> Option<&EncryptedLogin> { + self.accounts.get(index) + } + + pub fn replace_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> { + let login = self.get_encrypted_login_mut(&new_login.id)?; + *login = new_login; + Ok(()) + } + + pub fn remove_encrypted_login(&mut self, id: &AccountId) -> Option { if let Some(index) = self.accounts.iter().position(|account| &account.id == id) { log::info!("Removing from wallet file: {id}"); Some(self.accounts.remove(index)) @@ -44,43 +88,15 @@ impl StoredWallet { } } - #[allow(unused)] - pub fn encrypted_account_by_index(&self, index: usize) -> Option<&EncryptedAccount> { - self.accounts.get(index) - } - - fn encrypted_account( + pub fn decrypt_login( &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 self.encrypted_account(&new_account.id).is_ok() { - return Err(BackendError::IdAlreadyExistsInWallet); - } - self.accounts.push(new_account); - Ok(()) - } - - pub fn decrypt_account( - &self, - id: &WalletAccountId, + id: &AccountId, password: &UserPassword, - ) -> Result { - self.encrypted_account(id)?.decrypt_struct(password) + ) -> Result { + self.get_encrypted_login(id)?.decrypt_struct(password) } - pub fn decrypt_all(&self, password: &UserPassword) -> Result, BackendError> { + pub fn decrypt_all(&self, password: &UserPassword) -> Result, BackendError> { self .accounts .iter() @@ -102,39 +118,51 @@ impl Default for StoredWallet { } } +/// Each entry in the stored wallet file. An id field in plaintext and an encrypted stored login. #[derive(Serialize, Deserialize, Debug)] -pub(crate) struct EncryptedAccount { - pub id: WalletAccountId, - pub account: EncryptedData, +pub(crate) struct EncryptedLogin { + pub id: AccountId, + pub account: EncryptedData, } -// future-proofing +/// A stored login is either a account, such as a mnemonic, or a list of multiple accounts where +/// each has an inner id. Future proofed for having private key backed accounts. #[derive(Serialize, Deserialize, Debug, Zeroize)] #[serde(untagged)] #[zeroize(drop)] -pub(crate) enum StoredAccount { +pub(crate) enum StoredLogin { Mnemonic(MnemonicAccount), // PrivateKey(PrivateKeyAccount) + Multiple(MultipleAccounts), } -impl StoredAccount { +impl StoredLogin { pub(crate) fn new_mnemonic_backed_account( mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - ) -> StoredAccount { - StoredAccount::Mnemonic(MnemonicAccount { mnemonic, hd_path }) + ) -> Self { + Self::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 { + #[cfg(test)] + pub(crate) fn as_mnemonic_account(&self) -> Option<&MnemonicAccount> { match self { - StoredAccount::Mnemonic(account) => account.mnemonic(), + StoredLogin::Mnemonic(mn) => Some(mn), + StoredLogin::Multiple(_) => None, + } + } + + #[cfg(test)] + pub(crate) fn as_multiple_accounts(&self) -> Option<&MultipleAccounts> { + match self { + StoredLogin::Mnemonic(_) => None, + StoredLogin::Multiple(accounts) => Some(accounts), } } } -#[derive(Serialize, Deserialize, Debug)] +/// An account backed by a unique mnemonic. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub(crate) struct MnemonicAccount { mnemonic: bip39::Mnemonic, #[serde(with = "display_hd_path")] @@ -142,14 +170,27 @@ pub(crate) struct MnemonicAccount { } impl MnemonicAccount { + pub(crate) fn generate_new(&self) -> MnemonicAccount { + MnemonicAccount { + mnemonic: bip39::Mnemonic::generate(self.mnemonic().word_count()).unwrap(), + hd_path: self.hd_path().clone(), + } + } + pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { &self.mnemonic } - #[allow(unused)] pub(crate) fn hd_path(&self) -> &DerivationPath { &self.hd_path } + + pub(crate) fn into_multiple(self, id: AccountId) -> MultipleAccounts { + MultipleAccounts::new(WalletAccount { + id, + account: self.into(), + }) + } } impl Zeroize for MnemonicAccount { @@ -173,6 +214,114 @@ impl Drop for MnemonicAccount { } } +/// Multiple stored accounts, each entry having an id and a data field. +#[derive(Serialize, Deserialize, Debug, Zeroize, PartialEq, Eq)] +pub(crate) struct MultipleAccounts { + accounts: Vec, +} + +impl MultipleAccounts { + pub(crate) fn new(account: WalletAccount) -> Self { + MultipleAccounts { + accounts: vec![account], + } + } + + pub(crate) fn get_accounts(&self) -> impl Iterator { + self.accounts.iter() + } + + pub(crate) fn get_account(&self, id: &AccountId) -> Option<&WalletAccount> { + self.accounts.iter().find(|account| &account.id == id) + } + + #[allow(unused)] + pub(crate) fn len(&self) -> usize { + self.accounts.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub(crate) fn add( + &mut self, + id: AccountId, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + ) -> Result<(), BackendError> { + if self.get_account(&id).is_some() { + Err(BackendError::IdAlreadyExistsInStoredWalletLogin) + } else { + self + .accounts + .push(WalletAccount::new_mnemonic_backed_account( + id, mnemonic, hd_path, + )); + Ok(()) + } + } + + pub(crate) fn remove(&mut self, id: &AccountId) { + self.accounts.retain(|accounts| &accounts.id != id); + } +} + +impl From> for MultipleAccounts { + fn from(accounts: Vec) -> MultipleAccounts { + Self { accounts } + } +} + +/// An entry in the list of stored accounts +#[derive(Serialize, Deserialize, Debug, Zeroize, PartialEq, Eq)] +pub(crate) struct WalletAccount { + pub id: AccountId, + pub account: AccountData, +} + +impl WalletAccount { + pub(crate) fn new_mnemonic_backed_account( + id: AccountId, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + ) -> Self { + Self { + id, + account: AccountData::new_mnemonic_backed_account(mnemonic, hd_path), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Zeroize, PartialEq, Eq)] +#[serde(untagged)] +#[zeroize(drop)] +pub(crate) enum AccountData { + Mnemonic(MnemonicAccount), + // PrivateKey(PrivateKeyAccount) +} + +impl AccountData { + pub(crate) fn new_mnemonic_backed_account( + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + ) -> AccountData { + AccountData::Mnemonic(MnemonicAccount { mnemonic, hd_path }) + } + + pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { + match self { + AccountData::Mnemonic(account) => account.mnemonic(), + } + } +} + +impl From for AccountData { + fn from(mnemonic_account: MnemonicAccount) -> Self { + AccountData::Mnemonic(mnemonic_account) + } +} + mod display_hd_path { use cosmrs::bip32::DerivationPath; use serde::{Deserialize, Deserializer, Serializer}; diff --git a/nym-wallet/src-tauri/src/wallet_storage/mod.rs b/nym-wallet/src-tauri/src/wallet_storage/mod.rs index 862e23e682..8fb7d10775 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/mod.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/mod.rs @@ -1,17 +1,17 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -pub(crate) use crate::wallet_storage::password::{UserPassword, WalletAccountId}; +pub(crate) use crate::wallet_storage::password::{AccountId, UserPassword}; use crate::error::BackendError; use crate::platform_constants::{STORAGE_DIR_NAME, WALLET_INFO_FILENAME}; -use crate::wallet_storage::account_data::StoredAccount; +use crate::wallet_storage::account_data::StoredLogin; use crate::wallet_storage::encryption::encrypt_struct; use cosmrs::bip32::DerivationPath; use std::fs::{self, create_dir_all, OpenOptions}; use std::path::PathBuf; -use self::account_data::{EncryptedAccount, StoredWallet}; +use self::account_data::{EncryptedLogin, StoredWallet}; pub(crate) mod account_data; pub(crate) mod encryption; @@ -30,8 +30,9 @@ pub(crate) fn wallet_login_filepath() -> Result { get_storage_directory().map(|dir| dir.join(WALLET_INFO_FILENAME)) } +/// Load stored wallet file #[allow(unused)] -pub(crate) fn load_existing_wallet(password: &UserPassword) -> Result { +pub(crate) fn load_existing_wallet() -> Result { let store_dir = get_storage_directory()?; let filepath = store_dir.join(WALLET_INFO_FILENAME); load_existing_wallet_at_file(filepath) @@ -46,10 +47,12 @@ fn load_existing_wallet_at_file(filepath: PathBuf) -> Result Result { +) -> 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) @@ -57,16 +60,19 @@ pub(crate) fn load_existing_wallet_login_information( fn load_existing_wallet_login_information_at_file( filepath: PathBuf, - id: &WalletAccountId, + id: &AccountId, password: &UserPassword, -) -> Result { - load_existing_wallet_at_file(filepath)?.decrypt_account(id, password) +) -> Result { + load_existing_wallet_at_file(filepath)?.decrypt_login(id, password) } +/// Encrypt `mnemonic` and store it together with `id`. It is stored at the top-level. +/// Currently we enforce that we can only add entries with the same password as the other already +/// existing entries. This is not unlikely to change in the future. pub(crate) fn store_wallet_login_information( mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - id: WalletAccountId, + id: AccountId, password: &UserPassword, ) -> Result<(), BackendError> { // make sure the entire directory structure exists @@ -81,7 +87,7 @@ fn store_wallet_login_information_at_file( filepath: PathBuf, mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - id: WalletAccountId, + id: AccountId, password: &UserPassword, ) -> Result<(), BackendError> { let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { @@ -94,13 +100,13 @@ fn store_wallet_login_information_at_file( return Err(BackendError::WalletDifferentPasswordDetected); } - let new_account = StoredAccount::new_mnemonic_backed_account(mnemonic, hd_path); - let new_encrypted_account = EncryptedAccount { + let new_account = StoredLogin::new_mnemonic_backed_account(mnemonic, hd_path); + let new_encrypted_account = EncryptedLogin { id, account: encrypt_struct(&new_account, password)?, }; - stored_wallet.add_encrypted_account(new_encrypted_account)?; + stored_wallet.add_encrypted_login(new_encrypted_account)?; let file = OpenOptions::new() .create(true) @@ -111,16 +117,86 @@ fn store_wallet_login_information_at_file( Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) } -pub(crate) fn remove_wallet_login_information(id: &WalletAccountId) -> Result<(), BackendError> { +/// Append an account to an already existing top-level encrypted account entry. +/// If the existing top-level entry is just a single account, it will be converted to the first +/// account in the list of accounts associated with the encrypted entry. The inner id for this +/// entry will be set to the same as the outer, unencrypted, id. +pub(crate) fn append_account_to_wallet_login_information( + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: AccountId, + inner_id: AccountId, + 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); + + append_account_to_wallet_login_information_at_file( + filepath, mnemonic, hd_path, id, inner_id, password, + ) +} + +fn append_account_to_wallet_login_information_at_file( + filepath: PathBuf, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: AccountId, + inner_id: AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { + Err(BackendError::WalletFileNotFound) => StoredWallet::default(), + result => result?, + }; + + let mut decrypted_login = stored_wallet.decrypt_login(&id, password)?; + + // Since we can't clone the mnemonic, we have to perform a little dance were we add the mnemonic + // to the inner enum payload, while also converting by swapping if necessary. + if let StoredLogin::Multiple(ref mut accounts) = decrypted_login { + accounts.add(inner_id, mnemonic, hd_path)?; + } else if let StoredLogin::Mnemonic(ref mut account) = decrypted_login { + // Move out the account by swapping, since we can't clone. + let account = std::mem::replace(account, account.generate_new()); + // Convert the enum variant + let mut accounts = account.into_multiple(id.clone()); + accounts.add(inner_id, mnemonic, hd_path)?; + // Overwrite the stored login with the new enum variant + decrypted_login = StoredLogin::Multiple(accounts); + } + + let encrypted_accounts = EncryptedLogin { + id, + account: encrypt_struct(&decrypted_login, password)?, + }; + + stored_wallet.replace_encrypted_login(encrypted_accounts)?; + + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filepath)?; + + Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) +} + +/// Remove the entire encrypted login entry for the given `id`. This means potentially removing all +/// associated accounts! +/// If this was the last entry in the file, the file is removed. +pub(crate) fn remove_wallet_login_information(id: &AccountId) -> Result<(), BackendError> { let store_dir = get_storage_directory()?; let filepath = store_dir.join(WALLET_INFO_FILENAME); remove_wallet_login_information_at_file(filepath, id) } -pub(crate) fn remove_wallet_login_information_at_file( +fn remove_wallet_login_information_at_file( filepath: PathBuf, - id: &WalletAccountId, + id: &AccountId, ) -> Result<(), BackendError> { + log::warn!("Removing wallet account with id: {id}. This includes all associated accounts!"); let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { Err(BackendError::WalletFileNotFound) => StoredWallet::default(), result => result?, @@ -132,7 +208,7 @@ pub(crate) fn remove_wallet_login_information_at_file( } stored_wallet - .remove_account(id) + .remove_encrypted_login(id) .ok_or(BackendError::NoSuchIdInWallet)?; if stored_wallet.is_empty() { @@ -149,8 +225,76 @@ pub(crate) fn remove_wallet_login_information_at_file( } } +/// Remove an account from inside the encrypted login. +/// - If the encrypted login is just a single account, abort to be on the safe side. +/// - If it is the last associated account with that login, the encrypted login will be removed. +/// - If this was the last encrypted login in the file, it will be removed. +pub(crate) fn remove_account_from_wallet_login( + id: &AccountId, + inner_id: &AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + let store_dir = get_storage_directory()?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + remove_account_from_wallet_login_at_file(filepath, id, inner_id, password) +} + +fn remove_account_from_wallet_login_at_file( + filepath: PathBuf, + id: &AccountId, + inner_id: &AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + log::info!("Removing associated account from login account: {id}"); + let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { + Err(BackendError::WalletFileNotFound) => StoredWallet::default(), + result => result?, + }; + + let mut decrypted_login = stored_wallet.decrypt_login(id, password)?; + + let is_empty = match decrypted_login { + StoredLogin::Mnemonic(_) => { + log::warn!("Encountered mnemonic login instead of list of accounts, aborting"); + return Err(BackendError::WalletUnexpectedMnemonicAccount); + } + StoredLogin::Multiple(ref mut accounts) => { + accounts.remove(inner_id); + accounts.is_empty() + } + }; + + if is_empty { + stored_wallet + .remove_encrypted_login(id) + .ok_or(BackendError::NoSuchIdInWallet)?; + } else { + // Replace the encrypted login with the prune one. + let encrypted_accounts = EncryptedLogin { + id: id.clone(), + account: encrypt_struct(&decrypted_login, password)?, + }; + stored_wallet.replace_encrypted_login(encrypted_accounts)?; + } + + if stored_wallet.is_empty() { + log::info!("Removing file: {:#?}", filepath); + Ok(fs::remove_file(filepath)?) + } else { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filepath)?; + + Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) + } +} + #[cfg(test)] mod tests { + use crate::wallet_storage::account_data::WalletAccount; + use super::*; use config::defaults::COSMOS_DERIVATION_PATH; use std::str::FromStr; @@ -171,8 +315,8 @@ mod tests { let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); - let id1 = WalletAccountId::new("first".to_string()); - let id2 = WalletAccountId::new("second".to_string()); + let id1 = AccountId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); // Nothing was stored on the disk assert!(matches!( @@ -197,10 +341,13 @@ mod tests { 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()) + stored_wallet.get_encrypted_login_by_index(0).unwrap().id, + AccountId::new("first".to_string()) ); - let encrypted_blob = &stored_wallet.encrypted_account_by_index(0).unwrap().account; + let encrypted_blob = &stored_wallet + .get_encrypted_login_by_index(0) + .unwrap() + .account; // some actual ciphertext was saved assert!(!encrypted_blob.ciphertext().is_empty()); @@ -235,9 +382,12 @@ mod tests { 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()); + if let StoredLogin::Mnemonic(ref acc) = loaded_account { + assert_eq!(&dummy_account1, acc.mnemonic()); + assert_eq!(&cosmos_hd_path, acc.hd_path()); + } else { + todo!(); + } // Can't store extra account if you use different password assert!(matches!( @@ -264,7 +414,7 @@ mod tests { 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) + .get_encrypted_login_by_index(1) .unwrap() .account; @@ -275,18 +425,24 @@ mod tests { // first account should be unchanged 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()); + if let StoredLogin::Mnemonic(ref acc1) = loaded_account { + assert_eq!(&dummy_account1, acc1.mnemonic()); + assert_eq!(&cosmos_hd_path, acc1.hd_path()); + } else { + todo!(); + } let loaded_account = load_existing_wallet_login_information_at_file(wallet_file.clone(), &id2, &password).unwrap(); - let StoredAccount::Mnemonic(ref acc2) = loaded_account; - assert_eq!(&dummy_account2, acc2.mnemonic()); - assert_eq!(&different_hd_path, acc2.hd_path()); + if let StoredLogin::Mnemonic(ref acc2) = loaded_account { + assert_eq!(&dummy_account2, acc2.mnemonic()); + assert_eq!(&different_hd_path, acc2.hd_path()); + } else { + todo!(); + } // Fails to delete non-existent id in the wallet - let id3 = WalletAccountId::new("phony".to_string()); + let id3 = AccountId::new("phony".to_string()); assert!(matches!( remove_wallet_login_information_at_file(wallet_file.clone(), &id3), Err(BackendError::NoSuchIdInWallet), @@ -298,9 +454,12 @@ mod tests { // The first account should be unchanged 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()); + if let StoredLogin::Mnemonic(ref acc1) = loaded_account { + assert_eq!(&dummy_account1, acc1.mnemonic()); + assert_eq!(&cosmos_hd_path, acc1.hd_path()); + } else { + todo!(); + } // Delete the first account assert!(wallet_file.exists()); @@ -312,6 +471,8 @@ mod tests { #[test] fn decrypt_stored_wallet() { + pretty_env_logger::init(); + const SAVED_WALLET: &str = "src/wallet_storage/test-data/saved-wallet.json"; let wallet_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SAVED_WALLET); @@ -320,27 +481,109 @@ mod tests { let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); - let id1 = WalletAccountId::new("first".to_string()); - let id2 = WalletAccountId::new("second".to_string()); + let id1 = AccountId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); assert!(!wallet.password_can_decrypt_all(&bad_password)); assert!(wallet.password_can_decrypt_all(&password)); - let account1 = wallet.decrypt_account(&id1, &password).unwrap(); - let account2 = wallet.decrypt_account(&id2, &password).unwrap(); + let account1 = wallet.decrypt_login(&id1, &password).unwrap(); + let account2 = wallet.decrypt_login(&id2, &password).unwrap(); + + assert!(matches!(account1, StoredLogin::Mnemonic(_))); + assert!(matches!(account2, StoredLogin::Mnemonic(_))); let expected_account1 = bip39::Mnemonic::from_str("country mean universe text phone begin deputy reject result good cram illness common cluster proud swamp digital patrol spread bar face december base kick").unwrap(); let expected_account2 = bip39::Mnemonic::from_str("home mansion start quiz dress decide hint second dragon sunny juice always steak real minimum art rival skin draw total pulp foot goddess agent").unwrap(); - assert_eq!(account1.mnemonic(), &expected_account1); - assert_eq!(account2.mnemonic(), &expected_account2); + assert_eq!( + account1.as_mnemonic_account().unwrap().mnemonic(), + &expected_account1 + ); + assert_eq!( + account1.as_mnemonic_account().unwrap().hd_path(), + &cosmos_hd_path, + ); - let StoredAccount::Mnemonic(ref mnemonic1) = account1; - assert_eq!(mnemonic1.mnemonic(), &expected_account1); - assert_eq!(mnemonic1.hd_path(), &cosmos_hd_path); + assert_eq!( + account2.as_mnemonic_account().unwrap().mnemonic(), + &expected_account2 + ); + assert_eq!( + account2.as_mnemonic_account().unwrap().hd_path(), + &cosmos_hd_path, + ); + } - let StoredAccount::Mnemonic(ref mnemonic2) = account2; - assert_eq!(mnemonic2.mnemonic(), &expected_account2); - assert_eq!(mnemonic2.hd_path(), &cosmos_hd_path); + #[test] + fn append_a_third_account() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + + let dummy_account1 = bip39::Mnemonic::generate(24).unwrap(); + let dummy_account2 = bip39::Mnemonic::generate(24).unwrap(); + let dummy_account3 = bip39::Mnemonic::generate(24).unwrap(); + let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + + let password = UserPassword::new("password".to_string()); + + let id1 = AccountId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + let id3 = AccountId::new("third".to_string()); + + store_wallet_login_information_at_file( + wallet_file.clone(), + dummy_account1.clone(), + cosmos_hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + store_wallet_login_information_at_file( + wallet_file.clone(), + dummy_account2.clone(), + cosmos_hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Check that it's there as the correct non-multiple type + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id2, &password).unwrap(); + let acc2 = loaded_account.as_mnemonic_account().unwrap(); + assert_eq!(acc2.mnemonic(), &dummy_account2); + assert_eq!(acc2.hd_path(), &cosmos_hd_path); + + // Add a third mnenonic grouped together with the second one + append_account_to_wallet_login_information_at_file( + wallet_file.clone(), + dummy_account3.clone(), + cosmos_hd_path.clone(), + id2.clone(), + id3.clone(), + &password, + ) + .unwrap(); + + // Check that we can still load all three + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); + let acc1 = loaded_account.as_mnemonic_account().unwrap(); + assert_eq!(acc1.mnemonic(), &dummy_account1); + assert_eq!(acc1.hd_path(), &cosmos_hd_path); + + let loaded_accounts = + load_existing_wallet_login_information_at_file(wallet_file, &id2, &password).unwrap(); + let accounts = loaded_accounts.as_multiple_accounts().unwrap(); + + let expected = vec![ + WalletAccount::new_mnemonic_backed_account(id2, dummy_account2, cosmos_hd_path.clone()), + WalletAccount::new_mnemonic_backed_account(id3, dummy_account3, cosmos_hd_path), + ] + .into(); + + assert_eq!(accounts, &expected); } } diff --git a/nym-wallet/src-tauri/src/wallet_storage/password.rs b/nym-wallet/src-tauri/src/wallet_storage/password.rs index 04b85277a1..016c5d3e5c 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/password.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/password.rs @@ -6,22 +6,22 @@ use std::fmt; use serde::{Deserialize, Serialize}; use zeroize::Zeroize; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WalletAccountId(String); +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +pub(crate) struct AccountId(String); -impl WalletAccountId { - pub(crate) fn new(id: String) -> WalletAccountId { - WalletAccountId(id) +impl AccountId { + pub(crate) fn new(id: String) -> AccountId { + AccountId(id) } } -impl AsRef for WalletAccountId { +impl AsRef for AccountId { fn as_ref(&self) -> &str { self.0.as_ref() } } -impl fmt::Display for WalletAccountId { +impl fmt::Display for AccountId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) }