diff --git a/crates/bitwarden-vault/src/cipher/bank_account.rs b/crates/bitwarden-vault/src/cipher/bank_account.rs index 0da408748..e3ca7daf5 100644 --- a/crates/bitwarden-vault/src/cipher/bank_account.rs +++ b/crates/bitwarden-vault/src/cipher/bank_account.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "wasm")] use tsify::Tsify; -use super::cipher::CipherKind; +use super::cipher::{CipherKind, StrictDecrypt}; use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields}; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -46,6 +46,18 @@ pub struct BankAccountView { pub bank_contact_phone: Option, } +/// Minimal BankAccountView only including the needed details for list views +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct BankAccountListView { + /// The account number of the bank account. + pub account_number: Option, + /// The type of the bank account, e.g. Checking, Savings, etc. + pub account_type: Option, +} + impl CompositeEncryptable for BankAccountView { fn encrypt_composite( &self, @@ -88,6 +100,34 @@ impl Decryptable for BankAccoun } } +impl Decryptable for BankAccount { + fn decrypt( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeySlotId, + ) -> Result { + Ok(BankAccountListView { + account_number: self.account_number.decrypt(ctx, key).ok().flatten(), + account_type: self.account_type.decrypt(ctx, key).ok().flatten(), + }) + } +} + +impl Decryptable + for StrictDecrypt<&BankAccount> +{ + fn decrypt( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeySlotId, + ) -> Result { + Ok(BankAccountListView { + account_number: self.0.account_number.decrypt(ctx, key)?, + account_type: self.0.account_type.decrypt(ctx, key)?, + }) + } +} + impl CipherKind for BankAccount { fn decrypt_subtitle( &self, @@ -230,6 +270,52 @@ mod tests { assert_eq!(decrypted, test_bank_account_view()); } + #[test] + fn test_bank_account_list_view() { + let key = + SymmetricCryptoKey::try_from(TEST_VECTOR_BANK_KEY.to_string()).expect("valid test key"); + let key_store = create_test_crypto_with_user_key(key); + let key_slot = SymmetricKeySlotId::User; + let mut ctx = key_store.context(); + + let encrypted: BankAccount = + serde_json::from_str(TEST_VECTOR_BANK_JSON).expect("valid test vector JSON"); + let list_view: BankAccountListView = encrypted + .decrypt(&mut ctx, key_slot) + .expect("BankAccount should decrypt to a list view"); + + assert_eq!( + list_view, + BankAccountListView { + account_number: Some("1234567890".to_string()), + account_type: Some("Checking".to_string()), + } + ); + } + + #[test] + fn test_bank_account_list_view_strict() { + let key = + SymmetricCryptoKey::try_from(TEST_VECTOR_BANK_KEY.to_string()).expect("valid test key"); + let key_store = create_test_crypto_with_user_key(key); + let key_slot = SymmetricKeySlotId::User; + let mut ctx = key_store.context(); + + let encrypted: BankAccount = + serde_json::from_str(TEST_VECTOR_BANK_JSON).expect("valid test vector JSON"); + let list_view: BankAccountListView = StrictDecrypt(&encrypted) + .decrypt(&mut ctx, key_slot) + .expect("BankAccount should strictly decrypt to a list view"); + + assert_eq!( + list_view, + BankAccountListView { + account_number: Some("1234567890".to_string()), + account_type: Some("Checking".to_string()), + } + ); + } + #[test] fn test_subtitle_bank_account() { let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap(); diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index ec9eb94f4..fa0a397f8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -26,6 +26,7 @@ use wasm_bindgen::prelude::wasm_bindgen; use super::{ attachment, bank_account, + bank_account::BankAccountListView, blob::{decrypt_blob_cipher, encrypt_blob_cipher, try_parse_blob}, card, card::CardListView, @@ -484,7 +485,7 @@ pub enum CipherListViewType { Card(CardListView), Identity, SshKey, - BankAccount, + BankAccount(BankAccountListView), Passport, DriversLicense, } @@ -1119,7 +1120,16 @@ impl CipherView { } CipherType::Identity => CipherListViewType::Identity, CipherType::SshKey => CipherListViewType::SshKey, - CipherType::BankAccount => CipherListViewType::BankAccount, + CipherType::BankAccount => { + let bank_account = self + .bank_account + .as_ref() + .ok_or(CryptoError::MissingField("bank_account"))?; + CipherListViewType::BankAccount(BankAccountListView { + account_number: bank_account.account_number.clone(), + account_type: bank_account.account_type.clone(), + }) + } CipherType::DriversLicense => CipherListViewType::DriversLicense, CipherType::Passport => CipherListViewType::Passport, }; @@ -1208,13 +1218,20 @@ impl CipherView { drivers_license::build_subtitle_drivers_license( d.first_name.clone(), d.last_name.clone(), + d.issuing_state.clone(), ) }) .unwrap_or_default(), CipherType::Passport => self .passport .as_ref() - .map(|p| passport::build_subtitle_passport(p.given_name.clone(), p.surname.clone())) + .map(|p| { + passport::build_subtitle_passport( + p.given_name.clone(), + p.surname.clone(), + p.issuing_country.clone(), + ) + }) .unwrap_or_default(), } } @@ -1414,7 +1431,13 @@ pub(crate) fn lenient_decrypt_cipher_list_view( } CipherType::Identity => CipherListViewType::Identity, CipherType::SshKey => CipherListViewType::SshKey, - CipherType::BankAccount => CipherListViewType::BankAccount, + CipherType::BankAccount => { + let bank_account = cipher + .bank_account + .as_ref() + .ok_or(CryptoError::MissingField("bank_account"))?; + CipherListViewType::BankAccount(bank_account.decrypt(ctx, ciphers_key)?) + } CipherType::Passport => CipherListViewType::Passport, CipherType::DriversLicense => CipherListViewType::DriversLicense, }, @@ -1686,7 +1709,15 @@ fn strict_decrypt_cipher_list_view( } CipherType::Identity => CipherListViewType::Identity, CipherType::SshKey => CipherListViewType::SshKey, - CipherType::BankAccount => CipherListViewType::BankAccount, + CipherType::BankAccount => { + let bank_account = cipher + .bank_account + .as_ref() + .ok_or(CryptoError::MissingField("bank_account"))?; + CipherListViewType::BankAccount( + StrictDecrypt(bank_account).decrypt(ctx, ciphers_key)?, + ) + } CipherType::Passport => CipherListViewType::Passport, CipherType::DriversLicense => CipherListViewType::DriversLicense, }, @@ -3844,7 +3875,13 @@ mod tests { let list_view = decrypt_blob_list_view(&key_store, view); assert_eq!(list_view.name, "My Bank Account"); assert_eq!(list_view.subtitle, "Some Bank"); - assert!(matches!(list_view.r#type, CipherListViewType::BankAccount)); + assert_eq!( + list_view.r#type, + CipherListViewType::BankAccount(BankAccountListView { + account_number: Some("123456".to_string()), + account_type: None, + }) + ); assert_eq!( list_view.copyable_fields, vec![ diff --git a/crates/bitwarden-vault/src/cipher/drivers_license.rs b/crates/bitwarden-vault/src/cipher/drivers_license.rs index 62e6d735f..cf7a10331 100644 --- a/crates/bitwarden-vault/src/cipher/drivers_license.rs +++ b/crates/bitwarden-vault/src/cipher/drivers_license.rs @@ -108,7 +108,16 @@ impl CipherKind for DriversLicense { .as_ref() .map(|l| l.decrypt(ctx, key)) .transpose()?; - Ok(build_subtitle_drivers_license(first_name, last_name)) + let issuing_state: Option = self + .issuing_state + .as_ref() + .map(|l| l.decrypt(ctx, key)) + .transpose()?; + Ok(build_subtitle_drivers_license( + first_name, + last_name, + issuing_state, + )) } fn get_copyable_fields(&self, _: Option<&Cipher>) -> Vec { @@ -136,13 +145,28 @@ impl CipherKind for DriversLicense { pub(super) fn build_subtitle_drivers_license( first_name: Option, last_name: Option, + issuing_state: Option, ) -> String { - [first_name, last_name] - .into_iter() - .flatten() - .filter(|s| !s.is_empty()) - .collect::>() - .join(" ") + let mut subtitle = String::new(); + + if let Some(first_name) = first_name { + subtitle.push_str(&first_name); + } + if let Some(last_name) = last_name { + if !subtitle.is_empty() && !last_name.is_empty() { + subtitle.push(' '); + } + subtitle.push_str(&last_name); + } + + if let Some(issuing_state) = issuing_state { + if !subtitle.is_empty() && !issuing_state.is_empty() { + subtitle.push_str(", "); + } + subtitle.push_str(&issuing_state); + } + + subtitle } impl TryFrom for DriversLicense { @@ -276,6 +300,89 @@ mod tests { ); } + #[test] + fn test_subtitle_drivers_license_with_issuing_state() { + let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap(); + let key_store = create_test_crypto_with_user_key(key); + let key = SymmetricKeySlotId::User; + let mut ctx = key_store.context(); + + let first_name_encrypted = "John".to_owned().encrypt(&mut ctx, key).unwrap(); + let last_name_encrypted = "Doe".to_owned().encrypt(&mut ctx, key).unwrap(); + let issuing_state_encrypted = "NY".to_owned().encrypt(&mut ctx, key).unwrap(); + + let dl = DriversLicense { + first_name: Some(first_name_encrypted), + middle_name: None, + last_name: Some(last_name_encrypted), + date_of_birth: None, + license_number: None, + issuing_country: None, + issuing_state: Some(issuing_state_encrypted), + issue_date: None, + expiration_date: None, + issuing_authority: None, + license_class: None, + }; + + assert_eq!( + dl.decrypt_subtitle(&mut ctx, key).unwrap(), + "John Doe, NY".to_string() + ); + } + + #[test] + fn test_build_subtitle_drivers_license() { + // All fields present + assert_eq!( + build_subtitle_drivers_license( + Some("John".to_string()), + Some("Doe".to_string()), + Some("NY".to_string()), + ), + "John Doe, NY" + ); + + // Names only, no issuing state + assert_eq!( + build_subtitle_drivers_license(Some("John".to_string()), Some("Doe".to_string()), None), + "John Doe" + ); + + // Issuing state only + assert_eq!( + build_subtitle_drivers_license(None, None, Some("NY".to_string())), + "NY" + ); + + // Last name and issuing state, no first name + assert_eq!( + build_subtitle_drivers_license(None, Some("Doe".to_string()), Some("NY".to_string())), + "Doe, NY" + ); + + // Empty strings are treated as absent for separators + assert_eq!( + build_subtitle_drivers_license( + Some("".to_string()), + Some("".to_string()), + Some("NY".to_string()), + ), + "NY" + ); + assert_eq!( + build_subtitle_drivers_license( + Some("John".to_string()), + Some("".to_string()), + Some("NY".to_string()), + ), + "John, NY" + ); + + // Nothing present + assert_eq!(build_subtitle_drivers_license(None, None, None), ""); + } + #[test] fn test_get_copyable_fields_drivers_license() { let enc_str: EncString = "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap(); diff --git a/crates/bitwarden-vault/src/cipher/mod.rs b/crates/bitwarden-vault/src/cipher/mod.rs index 462b97aa8..7188dad4f 100644 --- a/crates/bitwarden-vault/src/cipher/mod.rs +++ b/crates/bitwarden-vault/src/cipher/mod.rs @@ -29,7 +29,7 @@ pub use attachment_client::{ CipherRenewFileUploadUrlError, CipherUpgradeAttachmentError, CreateAttachmentRequest, CreatedAttachment, DecryptFileError, DeleteAttachmentAdminError, EncryptFileError, }; -pub use bank_account::BankAccountView; +pub use bank_account::{BankAccountListView, BankAccountView}; pub use blob::{BlobEncryptionError, SealedCipherBlobError}; pub use card::{CardBrand, CardListView, CardView}; pub use cipher::{ diff --git a/crates/bitwarden-vault/src/cipher/passport.rs b/crates/bitwarden-vault/src/cipher/passport.rs index 7bc73db2f..810635415 100644 --- a/crates/bitwarden-vault/src/cipher/passport.rs +++ b/crates/bitwarden-vault/src/cipher/passport.rs @@ -122,7 +122,16 @@ impl CipherKind for Passport { .as_ref() .map(|s| s.decrypt(ctx, key)) .transpose()?; - Ok(build_subtitle_passport(given_name, surname)) + let issuing_country: Option = self + .issuing_country + .as_ref() + .map(|s| s.decrypt(ctx, key)) + .transpose()?; + Ok(build_subtitle_passport( + given_name, + surname, + issuing_country, + )) } fn get_copyable_fields(&self, _: Option<&Cipher>) -> Vec { @@ -150,13 +159,28 @@ impl CipherKind for Passport { pub(super) fn build_subtitle_passport( given_name: Option, surname: Option, + issuing_country: Option, ) -> String { - [given_name, surname] - .into_iter() - .flatten() - .filter(|s| !s.is_empty()) - .collect::>() - .join(" ") + let mut subtitle = String::new(); + + if let Some(given_name) = given_name { + subtitle.push_str(&given_name); + } + if let Some(surname) = surname { + if !subtitle.is_empty() && !surname.is_empty() { + subtitle.push(' '); + } + subtitle.push_str(&surname); + } + + if let Some(issuing_country) = issuing_country { + if !subtitle.is_empty() && !issuing_country.is_empty() { + subtitle.push_str(", "); + } + subtitle.push_str(&issuing_country); + } + + subtitle } impl TryFrom for Passport { @@ -302,6 +326,91 @@ mod tests { ); } + #[test] + fn test_subtitle_passport_with_issuing_country() { + let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap(); + let key_store = create_test_crypto_with_user_key(key); + let key = SymmetricKeySlotId::User; + let mut ctx = key_store.context(); + + let given_name_encrypted = "Jane".to_owned().encrypt(&mut ctx, key).unwrap(); + let surname_encrypted = "Doe".to_owned().encrypt(&mut ctx, key).unwrap(); + let issuing_country_encrypted = "US".to_owned().encrypt(&mut ctx, key).unwrap(); + + let passport = Passport { + surname: Some(surname_encrypted), + given_name: Some(given_name_encrypted), + date_of_birth: None, + sex: None, + birth_place: None, + nationality: None, + issuing_country: Some(issuing_country_encrypted), + passport_number: None, + passport_type: None, + national_identification_number: None, + issuing_authority: None, + issue_date: None, + expiration_date: None, + }; + + assert_eq!( + passport.decrypt_subtitle(&mut ctx, key).unwrap(), + "Jane Doe, US".to_string() + ); + } + + #[test] + fn test_build_subtitle_passport() { + // All fields present + assert_eq!( + build_subtitle_passport( + Some("Jane".to_string()), + Some("Doe".to_string()), + Some("US".to_string()), + ), + "Jane Doe, US" + ); + + // Names only, no issuing country + assert_eq!( + build_subtitle_passport(Some("Jane".to_string()), Some("Doe".to_string()), None), + "Jane Doe" + ); + + // Issuing country only + assert_eq!( + build_subtitle_passport(None, None, Some("US".to_string())), + "US" + ); + + // Surname and issuing country, no given name + assert_eq!( + build_subtitle_passport(None, Some("Doe".to_string()), Some("US".to_string())), + "Doe, US" + ); + + // Empty strings are treated as absent for separators + assert_eq!( + build_subtitle_passport( + Some("".to_string()), + Some("".to_string()), + Some("US".to_string()), + ), + "US" + ); + assert_eq!( + build_subtitle_passport( + Some("Jane".to_string()), + Some("".to_string()), + Some("US".to_string()), + ), + "Jane, US" + ); + + // Nothing present + assert_eq!(build_subtitle_passport(None, None, None), ""); + } + #[test] fn test_get_copyable_fields_passport() { let enc_str: EncString = "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap();