diff --git a/nym-wallet/src-tauri/src/error.rs b/nym-wallet/src-tauri/src/error.rs index 92cfc19562..64a74028b7 100644 --- a/nym-wallet/src-tauri/src/error.rs +++ b/nym-wallet/src-tauri/src/error.rs @@ -83,6 +83,12 @@ pub enum BackendError { NoValidatorApiUrlConfigured, #[error("The wallet file already exists")] WalletFileAlreadyExists, + #[error("The wallet file is not found")] + WalletNotFound, + #[error("Account ID not found in wallet")] + NoSuchWalletId, + #[error("Adding a different password to the wallet not currently supported")] + WalletDifferentPasswordDetected, } impl Serialize for BackendError { diff --git a/nym-wallet/src-tauri/src/operations/mixnet/account.rs b/nym-wallet/src-tauri/src/operations/mixnet/account.rs index b1ed58fcfc..63ada03ad7 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/account.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/account.rs @@ -326,26 +326,28 @@ async fn is_validator_connection_ok(client: &Client) -> bool pub fn does_password_file_exist() -> Result { log::info!("Checking wallet file"); let file = wallet_storage::wallet_login_filepath()?; - if file.is_file() { + if file.exists() { log::info!("Exists: {}", file.to_string_lossy()); } else { log::info!("Does not exist: {}", file.to_string_lossy()); } - Ok(file.is_file()) + Ok(file.exists()) } #[tauri::command] pub fn create_password(mnemonic: String, password: String) -> Result<(), BackendError> { - log::info!("Creating password"); 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(); let password = wallet_storage::UserPassword::new(password); - wallet_storage::store_wallet_login_information(mnemonic, hd_path, &password)?; - Ok(()) + // WIP(JON): extract this one out + let id = wallet_storage::UserId::new("default".to_string()); + + wallet_storage::store_wallet_login_information(mnemonic, hd_path, id, &password) } #[tauri::command] @@ -355,10 +357,9 @@ pub async fn sign_in_with_password( ) -> Result { log::info!("Signing in with password"); let password = wallet_storage::UserPassword::new(password); - let stored_accounts = wallet_storage::load_existing_wallet_login_information(&password)?; + let id = wallet_storage::UserId::new("default".to_string()); + let stored_account = wallet_storage::load_existing_wallet_login_information(&id, &password)?; - // WIP: we are assuming just a single password is stored - let stored_account = stored_accounts.into_iter().next().unwrap(); let mnemonic = match stored_account { wallet_storage::account_data::StoredAccount::Mnemonic(ref mn) => mn.mnemonic().clone(), }; 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 f7ba738b93..972019503d 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,74 @@ use std::fmt::Formatter; use zeroize::Zeroize; use zeroize::Zeroizing; +use crate::error::BackendError; + +use super::encryption::EncryptedData; +use super::password::UserId; +use super::UserPassword; + +const CURRENT_WALLET_FILE_VERSION: u32 = 1; + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct StoredWallet { + pub version: u32, + pub accounts: Vec, +} + +impl StoredWallet { + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub fn len(&self) -> usize { + self.accounts.len() + } + + fn encrypted_account(&self, id: &UserId) -> Result<&EncryptedData, BackendError> { + self + .accounts + .iter() + .find(|account| &account.id == id) + .map(|account| &account.account) + .ok_or(BackendError::NoSuchWalletId) + } + + pub fn decrypt_account( + &self, + id: &UserId, + 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: UserId, + pub account: EncryptedData, +} + // future-proofing #[derive(Serialize, Deserialize, Debug, Zeroize)] #[serde(untagged)] diff --git a/nym-wallet/src-tauri/src/wallet_storage/encryption.rs b/nym-wallet/src-tauri/src/wallet_storage/encryption.rs index af30b350af..fbac118f89 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(); } } @@ -182,6 +182,7 @@ where T: Serialize, { let bytes = serde_json::to_vec(data).map_err(|_| BackendError::EncryptionError)?; + //dbg!(&bytes); let (salt, iv) = random_salt_and_iv(); let ciphertext = encrypt(&bytes, password, &salt, &iv)?; diff --git a/nym-wallet/src-tauri/src/wallet_storage/mod.rs b/nym-wallet/src-tauri/src/wallet_storage/mod.rs index c9705ed464..875f50a390 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/mod.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/mod.rs @@ -1,16 +1,20 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -pub(crate) use crate::wallet_storage::password::UserPassword; +use crate::operations::mixnet::account::create_new_account; +pub(crate) use crate::wallet_storage::password::{UserId, 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::encryption::{encrypt_struct, EncryptedData}; 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; @@ -26,30 +30,42 @@ 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::WalletNotFound); + } + 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: &UserId, + 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: &UserId, 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, + id: UserId, password: &UserPassword, ) -> Result<(), BackendError> { // make sure the entire directory structure exists @@ -57,21 +73,33 @@ pub(crate) fn store_wallet_login_information( 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: UserId, 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::WalletNotFound) => 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.accounts.push(new_encrypted_account); let file = OpenOptions::new() .create(true) @@ -79,9 +107,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,16 +117,13 @@ fn store_wallet_login_information_at_file( #[cfg(test)] mod tests { + use crate::wallet_storage::encryption::encrypt_data; + use super::*; 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() { @@ -115,29 +138,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 = UserId::new("first".to_string()); + let id2 = UserId::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::WalletNotFound), + )); + assert!(matches!( + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password), + Err(BackendError::WalletNotFound), + )); + + // 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.accounts[0].id, + UserId::new("first".to_string()) + ); + let encrypted_blob = &stored_wallet.accounts[0].account; // some actual ciphertext was saved assert!(!encrypted_blob.ciphertext().is_empty()); @@ -147,53 +177,65 @@ 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::NoSuchWalletId), + )); - let loaded_accounts = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &password).unwrap(); - println!("{:?}", loaded_accounts); - assert_eq!(1, loaded_accounts.len()); + let loaded_account = + load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); - let StoredAccount::Mnemonic(acc) = &loaded_accounts[0]; + 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.accounts[1].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()); + // WIP(JON): test that a re-saved account has new IV and salt // 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..eb4025cdf8 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/password.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/password.rs @@ -3,6 +3,24 @@ use zeroize::Zeroize; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct UserId(String); + +impl UserId { + // WIP(JON): consider making inner String pub + pub(crate) fn new(id: String) -> UserId { + UserId(id) + } +} + +impl AsRef for UserId { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + // simple wrapper for String that will get zeroized on drop #[derive(Zeroize)] #[zeroize(drop)]