From 94b1eaddc4a268e6278a37b36eb90e9ea5391e0e Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 8 Sep 2025 16:55:40 -0700 Subject: [PATCH 01/12] Add data field to Cipher, and basic json deserialization in CiphersClient::migrate --- .../bitwarden-vault/src/cipher/attachment.rs | 1 + crates/bitwarden-vault/src/cipher/cipher.rs | 15 +++++++++----- .../src/cipher/cipher_client.rs | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/attachment.rs b/crates/bitwarden-vault/src/cipher/attachment.rs index ba8c9bb5d..aab37e390 100644 --- a/crates/bitwarden-vault/src/cipher/attachment.rs +++ b/crates/bitwarden-vault/src/cipher/attachment.rs @@ -342,6 +342,7 @@ mod tests { deleted_date: None, revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(), archived_date: None, + data: None, }; let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap(); diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9c6b36173..bf10ccc03 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -106,11 +106,10 @@ pub struct EncryptionContext { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Cipher { - pub id: Option, - pub organization_id: Option, - pub folder_id: Option, - pub collection_ids: Vec, - + pub id: Option, + pub organization_id: Option, + pub folder_id: Option, + pub collection_ids: Vec, /// More recent ciphers uses individual encryption keys to encrypt the other fields of the /// Cipher. pub key: Option, @@ -796,6 +795,12 @@ impl From for CipherRepromptType } } +// impl From<&Cipher> for Option { +// fn from(value: &Cipher) -> Self { + +// } +// } + #[cfg(test)] mod tests { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client.rs index 060e1bcfd..ff7143525 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client.rs @@ -153,6 +153,26 @@ impl CiphersClient { Ok(cipher_view) } + #[allow(missing_docs)] + pub fn migrate(&self, mut cipher: Cipher) -> Cipher { + let Ok(data) = serde_json::to_value(&cipher.data) else { + // If we can't deserialize the data, then we'll return the cipher as-is. + return cipher; + }; + let version = data.get("version").and_then(|v| v.as_u64()); + + // TODO: Matching on version here - for now, just matching some types. + match cipher.r#type { + crate::CipherType::Login => cipher.login = serde_json::from_value(data).ok(), + crate::CipherType::SecureNote => cipher.secure_note = serde_json::from_value(data).ok(), + crate::CipherType::Card => cipher.card = serde_json::from_value(data).ok(), + crate::CipherType::Identity => cipher.identity = serde_json::from_value(data).ok(), + crate::CipherType::SshKey => cipher.ssh_key = serde_json::from_value(data).ok(), + } + + cipher + } + #[allow(missing_docs)] pub fn move_to_organization( &self, From 91e1e1e73cb2644f3cab0a207211d4a818d1c27f Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Tue, 9 Sep 2025 16:58:58 -0700 Subject: [PATCH 02/12] Consume exisiting migrate function, with ciphertype extractions --- .../src/cipher/cipher_client.rs | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client.rs index ff7143525..299cec628 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client.rs @@ -153,26 +153,6 @@ impl CiphersClient { Ok(cipher_view) } - #[allow(missing_docs)] - pub fn migrate(&self, mut cipher: Cipher) -> Cipher { - let Ok(data) = serde_json::to_value(&cipher.data) else { - // If we can't deserialize the data, then we'll return the cipher as-is. - return cipher; - }; - let version = data.get("version").and_then(|v| v.as_u64()); - - // TODO: Matching on version here - for now, just matching some types. - match cipher.r#type { - crate::CipherType::Login => cipher.login = serde_json::from_value(data).ok(), - crate::CipherType::SecureNote => cipher.secure_note = serde_json::from_value(data).ok(), - crate::CipherType::Card => cipher.card = serde_json::from_value(data).ok(), - crate::CipherType::Identity => cipher.identity = serde_json::from_value(data).ok(), - crate::CipherType::SshKey => cipher.ssh_key = serde_json::from_value(data).ok(), - } - - cipher - } - #[allow(missing_docs)] pub fn move_to_organization( &self, @@ -194,6 +174,29 @@ impl CiphersClient { let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?; Ok(decrypted_key) } + + #[allow(missing_docs)] + fn extract_cipher_types(&self, mut cipher: Cipher) -> Cipher { + let Ok(mut data) = serde_json::to_value(&cipher.data) else { + // If we can't deserialize the data, then we'll return the cipher as-is. + // Should maybe return a result instead? + return cipher; + }; + let _version = data.get("version").and_then(|v| v.as_u64()).unwrap_or(1); + if let Some(data) = data.as_object_mut() { + data.insert("version".to_string(), _version.into()); + } + + match cipher.r#type { + crate::CipherType::Login => cipher.login = serde_json::from_value(data).ok(), + crate::CipherType::SecureNote => cipher.secure_note = serde_json::from_value(data).ok(), + crate::CipherType::Card => cipher.card = serde_json::from_value(data).ok(), + crate::CipherType::Identity => cipher.identity = serde_json::from_value(data).ok(), + crate::CipherType::SshKey => cipher.ssh_key = serde_json::from_value(data).ok(), + } + + cipher + } } #[cfg(test)] From 7c147644ecc7501156a71aea9dc96e8d26368529 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 10 Sep 2025 16:57:30 -0700 Subject: [PATCH 03/12] Move extract_cipher_types to be a function on Cipher --- crates/bitwarden-vault/src/cipher/cipher.rs | 29 +++++++++++++++++++ .../src/cipher/cipher_client.rs | 1 + 2 files changed, 30 insertions(+) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index bf10ccc03..f05cb7089 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -447,6 +447,35 @@ impl Cipher { .map(|kind| kind.get_copyable_fields(Some(self))) .unwrap_or_default() } + + + /// Extracts and sets the CipherType-specific fields from the opaque `data` field. + pub(crate) fn populate_cipher_types(&mut self) { + let Ok(mut data) = serde_json::to_value(&self.data) else { + // If we can't deserialize the data, then we'll return the cipher as-is. + // Should maybe return a result instead? + return; + }; + let _version = data.get("version").and_then(|v| v.as_u64()).unwrap_or(1); + if let Some(data) = data.as_object_mut() { + data.insert("version".to_string(), _version.into()); + data.insert("username".to_string(), serde_json::json!("2.uXRs3AGTuyJc7DhIDjw1ig==|A8wYld5QW+TxBEDM/lvWdA==|/zcjqwTtPnbQ1Pyr0r1Y5u3JSHUc2fE3c5OdDrVbBXc=")); + } + + + // TODO: Matching on version here - for now, just matching some types. + match &self.r#type { + crate::CipherType::Login => self.login = { + let mut login = serde_json::from_value(data).ok(); + login.as_mut().map(|l:&mut Login|l.username="".parse().ok()); // HArdocidng for test + login + }, + crate::CipherType::SecureNote => self.secure_note = serde_json::from_value(data).ok(), + crate::CipherType::Card => self.card = serde_json::from_value(data).ok(), + crate::CipherType::Identity => self.identity = serde_json::from_value(data).ok(), + crate::CipherType::SshKey => self.ssh_key = serde_json::from_value(data).ok(), + } + } } impl CipherView { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client.rs index 299cec628..725e41765 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client.rs @@ -349,6 +349,7 @@ mod tests { deleted_date: None, revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(), archived_date: None, + data: None, }]) .unwrap(); From b5f7869c18d50bb7e201b9d897e004d8a1343ebd Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Thu, 11 Sep 2025 17:43:42 -0700 Subject: [PATCH 04/12] Extract cipher types for cipher.rs --- crates/bitwarden-vault/src/cipher/card.rs | 2 +- crates/bitwarden-vault/src/cipher/cipher.rs | 24 ++++++------------- crates/bitwarden-vault/src/cipher/identity.rs | 2 +- crates/bitwarden-vault/src/cipher/login.rs | 2 +- .../bitwarden-vault/src/cipher/secure_note.rs | 2 +- crates/bitwarden-vault/src/cipher/ssh_key.rs | 2 +- 6 files changed, 12 insertions(+), 22 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/card.rs b/crates/bitwarden-vault/src/cipher/card.rs index 6c0507502..851182bb7 100644 --- a/crates/bitwarden-vault/src/cipher/card.rs +++ b/crates/bitwarden-vault/src/cipher/card.rs @@ -12,7 +12,7 @@ use super::cipher::CipherKind; use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields}; #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Card { diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index f05cb7089..8e447409b 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -6,8 +6,8 @@ use bitwarden_core::{ require, }; use bitwarden_crypto::{ - CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext, - PrimitiveEncryptable, + CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, + KeyStoreContext, PrimitiveEncryptable, }; use bitwarden_error::bitwarden_error; use bitwarden_uuid::uuid_newtype; @@ -448,28 +448,18 @@ impl Cipher { .unwrap_or_default() } - /// Extracts and sets the CipherType-specific fields from the opaque `data` field. - pub(crate) fn populate_cipher_types(&mut self) { - let Ok(mut data) = serde_json::to_value(&self.data) else { + pub(crate) fn populate_cipher_types(&mut self) { + let Ok(data) = + serde_json::from_str::(&self.data.as_deref().unwrap_or("{}")) + else { // If we can't deserialize the data, then we'll return the cipher as-is. // Should maybe return a result instead? return; }; - let _version = data.get("version").and_then(|v| v.as_u64()).unwrap_or(1); - if let Some(data) = data.as_object_mut() { - data.insert("version".to_string(), _version.into()); - data.insert("username".to_string(), serde_json::json!("2.uXRs3AGTuyJc7DhIDjw1ig==|A8wYld5QW+TxBEDM/lvWdA==|/zcjqwTtPnbQ1Pyr0r1Y5u3JSHUc2fE3c5OdDrVbBXc=")); - } - - // TODO: Matching on version here - for now, just matching some types. match &self.r#type { - crate::CipherType::Login => self.login = { - let mut login = serde_json::from_value(data).ok(); - login.as_mut().map(|l:&mut Login|l.username="".parse().ok()); // HArdocidng for test - login - }, + crate::CipherType::Login => self.login = serde_json::from_value(data).ok(), crate::CipherType::SecureNote => self.secure_note = serde_json::from_value(data).ok(), crate::CipherType::Card => self.card = serde_json::from_value(data).ok(), crate::CipherType::Identity => self.identity = serde_json::from_value(data).ok(), diff --git a/crates/bitwarden-vault/src/cipher/identity.rs b/crates/bitwarden-vault/src/cipher/identity.rs index 801c69b18..31be5170d 100644 --- a/crates/bitwarden-vault/src/cipher/identity.rs +++ b/crates/bitwarden-vault/src/cipher/identity.rs @@ -12,7 +12,7 @@ use super::cipher::CipherKind; use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields}; #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Identity { diff --git a/crates/bitwarden-vault/src/cipher/login.rs b/crates/bitwarden-vault/src/cipher/login.rs index 814f68385..c0cc51de4 100644 --- a/crates/bitwarden-vault/src/cipher/login.rs +++ b/crates/bitwarden-vault/src/cipher/login.rs @@ -280,7 +280,7 @@ impl Decryptable for Fido2Crede #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Login { diff --git a/crates/bitwarden-vault/src/cipher/secure_note.rs b/crates/bitwarden-vault/src/cipher/secure_note.rs index 022fc651a..39e4716ea 100644 --- a/crates/bitwarden-vault/src/cipher/secure_note.rs +++ b/crates/bitwarden-vault/src/cipher/secure_note.rs @@ -26,7 +26,7 @@ pub enum SecureNoteType { } #[derive(Clone, Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SecureNote { diff --git a/crates/bitwarden-vault/src/cipher/ssh_key.rs b/crates/bitwarden-vault/src/cipher/ssh_key.rs index c199f1fbd..b9f2e2758 100644 --- a/crates/bitwarden-vault/src/cipher/ssh_key.rs +++ b/crates/bitwarden-vault/src/cipher/ssh_key.rs @@ -15,7 +15,7 @@ use super::cipher::CipherKind; use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields}; #[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SshKey { From fb4eef4501a818ab1ec7b2447a4a649532150cfa Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 12 Sep 2025 12:10:53 -0700 Subject: [PATCH 05/12] Add tests for populate_cipher_types --- crates/bitwarden-vault/src/cipher/cipher.rs | 305 +++++++++++++++++++- 1 file changed, 302 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 8e447409b..005a3f240 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -6,8 +6,8 @@ use bitwarden_core::{ require, }; use bitwarden_crypto::{ - CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, - KeyStoreContext, PrimitiveEncryptable, + CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext, + PrimitiveEncryptable, }; use bitwarden_error::bitwarden_error; use bitwarden_uuid::uuid_newtype; @@ -902,7 +902,7 @@ mod tests { folder_id: None, collection_ids: vec![], key: None, - name: "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=".parse().unwrap(), + name: TEST_CIPHER_NAME.parse().unwrap(), notes: None, r#type: CipherType::Login, login: Some(Login { @@ -1363,4 +1363,303 @@ mod tests { let decrypted_key_value = cipher_view.decrypt_fido2_private_key(&mut ctx).unwrap(); assert_eq!(decrypted_key_value, "123"); } + + // Test constants for encrypted strings + const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk="; + const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo="; + const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; + const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4="; + const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk="; + const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; + const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69"; + + #[test] + fn test_populate_cipher_types_login_with_valid_data() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some(format!(r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2)), + }; + + cipher.populate_cipher_types(); + + assert!(cipher.login.is_some()); + let login = cipher.login.unwrap(); + assert_eq!(login.username.unwrap().to_string(), TEST_ENC_STRING_1); + assert_eq!(login.password.unwrap().to_string(), TEST_ENC_STRING_2); + } + + #[test] + fn test_populate_cipher_types_secure_note() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::SecureNote, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()), + }; + + cipher.populate_cipher_types(); + + assert!(cipher.secure_note.is_some()); + } + + #[test] + fn test_populate_cipher_types_card() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::Card, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some(format!(r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5, TEST_ENC_STRING_1)), + }; + + cipher.populate_cipher_types(); + + assert!(cipher.card.is_some()); + let card = cipher.card.unwrap(); + assert_eq!(card.cardholder_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); + assert_eq!(card.number.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); + assert_eq!(card.exp_month.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); + assert_eq!(card.exp_year.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); + assert_eq!(card.code.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); + assert_eq!(card.brand.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); + } + + #[test] + fn test_populate_cipher_types_identity() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::Identity, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some(format!(r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5)), + }; + + cipher.populate_cipher_types(); + + assert!(cipher.identity.is_some()); + let identity = cipher.identity.unwrap(); + assert_eq!(identity.first_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); + assert_eq!(identity.last_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); + assert_eq!(identity.email.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); + assert_eq!(identity.phone.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); + assert_eq!(identity.company.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); + assert_eq!(identity.address1.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); + assert_eq!(identity.city.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); + assert_eq!(identity.state.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); + assert_eq!(identity.postal_code.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); + assert_eq!(identity.country.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); + } + + #[test] + fn test_populate_cipher_types_ssh_key() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::SshKey, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some(format!(r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3)), + }; + + cipher.populate_cipher_types(); + + assert!(cipher.ssh_key.is_some()); + let ssh_key = cipher.ssh_key.unwrap(); + assert_eq!(ssh_key.private_key.to_string(), TEST_ENC_STRING_1); + assert_eq!(ssh_key.public_key.to_string(), TEST_ENC_STRING_2); + assert_eq!(ssh_key.fingerprint.to_string(), TEST_ENC_STRING_3); + } + + #[test] + fn test_populate_cipher_types_with_null_data() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: None, + }; + + cipher.populate_cipher_types(); + + // Should not crash and login should be created (empty Login with None fields) + assert!(cipher.login.is_some()); + let login = cipher.login.unwrap(); + assert!(login.username.is_none()); + assert!(login.password.is_none()); + } + + #[test] + fn test_populate_cipher_types_with_invalid_json() { + let mut cipher = Cipher { + id: Some(TEST_UUID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: TEST_CIPHER_NAME.parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + permissions: None, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + data: Some("invalid json".to_string()), + }; + + cipher.populate_cipher_types(); + + // Should not crash and login should remain None + assert!(cipher.login.is_none()); + } + } From 7fb7cf3d191abfa129e844cbaaa09ced348f9989 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 12 Sep 2025 12:42:23 -0700 Subject: [PATCH 06/12] Fix clippy errors --- crates/bitwarden-vault/src/cipher/cipher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 005a3f240..3130fe7d6 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -451,7 +451,7 @@ impl Cipher { /// Extracts and sets the CipherType-specific fields from the opaque `data` field. pub(crate) fn populate_cipher_types(&mut self) { let Ok(data) = - serde_json::from_str::(&self.data.as_deref().unwrap_or("{}")) + serde_json::from_str::(self.data.as_deref().unwrap_or("{}")) else { // If we can't deserialize the data, then we'll return the cipher as-is. // Should maybe return a result instead? From 3df87a1483bd17f50d5a36a3509dcf78ebcebda2 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 17 Sep 2025 16:20:59 -0700 Subject: [PATCH 07/12] Update Cipher::populate_cipher_types with better error handling. --- crates/bitwarden-vault/src/cipher/cipher.rs | 174 ++++++++++++++------ crates/bitwarden-vault/src/error.rs | 17 +- 2 files changed, 142 insertions(+), 49 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 3130fe7d6..91f109e80 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -50,6 +50,10 @@ pub enum CipherError { "This cipher contains attachments without keys. Those attachments will need to be reuploaded to complete the operation" )] AttachmentsWithoutKeys, + #[error(transparent)] + Chrono(#[from] chrono::ParseError), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), } /// Helper trait for operations on cipher types. @@ -448,26 +452,27 @@ impl Cipher { .unwrap_or_default() } - /// Extracts and sets the CipherType-specific fields from the opaque `data` field. - pub(crate) fn populate_cipher_types(&mut self) { - let Ok(data) = - serde_json::from_str::(self.data.as_deref().unwrap_or("{}")) - else { - // If we can't deserialize the data, then we'll return the cipher as-is. - // Should maybe return a result instead? - return; - }; + /// This replaces the values provided by the API in the `login`, `secure_note`, `card`, + /// `identity`, and `ssh_key` fields, relying instead on client-side parsing of the + /// `data` field. + pub(crate) fn populate_cipher_types(&mut self) -> Result<(), VaultParseError> { + let data = self + .data + .as_ref() + .ok_or(VaultParseError::MissingFieldError(MissingFieldError( + "data", + )))?; match &self.r#type { - crate::CipherType::Login => self.login = serde_json::from_value(data).ok(), - crate::CipherType::SecureNote => self.secure_note = serde_json::from_value(data).ok(), - crate::CipherType::Card => self.card = serde_json::from_value(data).ok(), - crate::CipherType::Identity => self.identity = serde_json::from_value(data).ok(), - crate::CipherType::SshKey => self.ssh_key = serde_json::from_value(data).ok(), + crate::CipherType::Login => self.login = serde_json::from_str(data)?, + crate::CipherType::SecureNote => self.secure_note = serde_json::from_str(data)?, + crate::CipherType::Card => self.card = serde_json::from_str(data)?, + crate::CipherType::Identity => self.identity = serde_json::from_str(data)?, + crate::CipherType::SshKey => self.ssh_key = serde_json::from_str(data)?, } + Ok(()) } } - impl CipherView { #[allow(missing_docs)] pub fn generate_cipher_key( @@ -1402,10 +1407,15 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), - data: Some(format!(r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2)), + data: Some(format!( + r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, + TEST_ENC_STRING_1, TEST_ENC_STRING_2 + )), }; - cipher.populate_cipher_types(); + cipher + .populate_cipher_types() + .expect("populate_cipher_types failed"); assert!(cipher.login.is_some()); let login = cipher.login.unwrap(); @@ -1445,7 +1455,9 @@ mod tests { data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()), }; - cipher.populate_cipher_types(); + cipher + .populate_cipher_types() + .expect("populate_cipher_types failed"); assert!(cipher.secure_note.is_some()); } @@ -1479,17 +1491,36 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), - data: Some(format!(r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5, TEST_ENC_STRING_1)), + data: Some(format!( + r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, + TEST_ENC_STRING_1, + TEST_ENC_STRING_2, + TEST_ENC_STRING_3, + TEST_ENC_STRING_4, + TEST_ENC_STRING_5, + TEST_ENC_STRING_1 + )), }; - cipher.populate_cipher_types(); + cipher + .populate_cipher_types() + .expect("populate_cipher_types failed"); assert!(cipher.card.is_some()); let card = cipher.card.unwrap(); - assert_eq!(card.cardholder_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); + assert_eq!( + card.cardholder_name.as_ref().unwrap().to_string(), + TEST_ENC_STRING_1 + ); assert_eq!(card.number.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); - assert_eq!(card.exp_month.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); - assert_eq!(card.exp_year.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); + assert_eq!( + card.exp_month.as_ref().unwrap().to_string(), + TEST_ENC_STRING_3 + ); + assert_eq!( + card.exp_year.as_ref().unwrap().to_string(), + TEST_ENC_STRING_4 + ); assert_eq!(card.code.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); assert_eq!(card.brand.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); } @@ -1523,23 +1554,67 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), - data: Some(format!(r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3, TEST_ENC_STRING_4, TEST_ENC_STRING_5)), + data: Some(format!( + r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#, + TEST_ENC_STRING_1, + TEST_ENC_STRING_2, + TEST_ENC_STRING_3, + TEST_ENC_STRING_4, + TEST_ENC_STRING_5, + TEST_ENC_STRING_1, + TEST_ENC_STRING_2, + TEST_ENC_STRING_3, + TEST_ENC_STRING_4, + TEST_ENC_STRING_5 + )), }; - cipher.populate_cipher_types(); + cipher + .populate_cipher_types() + .expect("populate_cipher_types failed"); assert!(cipher.identity.is_some()); let identity = cipher.identity.unwrap(); - assert_eq!(identity.first_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); - assert_eq!(identity.last_name.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); - assert_eq!(identity.email.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); - assert_eq!(identity.phone.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); - assert_eq!(identity.company.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); - assert_eq!(identity.address1.as_ref().unwrap().to_string(), TEST_ENC_STRING_1); - assert_eq!(identity.city.as_ref().unwrap().to_string(), TEST_ENC_STRING_2); - assert_eq!(identity.state.as_ref().unwrap().to_string(), TEST_ENC_STRING_3); - assert_eq!(identity.postal_code.as_ref().unwrap().to_string(), TEST_ENC_STRING_4); - assert_eq!(identity.country.as_ref().unwrap().to_string(), TEST_ENC_STRING_5); + assert_eq!( + identity.first_name.as_ref().unwrap().to_string(), + TEST_ENC_STRING_1 + ); + assert_eq!( + identity.last_name.as_ref().unwrap().to_string(), + TEST_ENC_STRING_2 + ); + assert_eq!( + identity.email.as_ref().unwrap().to_string(), + TEST_ENC_STRING_3 + ); + assert_eq!( + identity.phone.as_ref().unwrap().to_string(), + TEST_ENC_STRING_4 + ); + assert_eq!( + identity.company.as_ref().unwrap().to_string(), + TEST_ENC_STRING_5 + ); + assert_eq!( + identity.address1.as_ref().unwrap().to_string(), + TEST_ENC_STRING_1 + ); + assert_eq!( + identity.city.as_ref().unwrap().to_string(), + TEST_ENC_STRING_2 + ); + assert_eq!( + identity.state.as_ref().unwrap().to_string(), + TEST_ENC_STRING_3 + ); + assert_eq!( + identity.postal_code.as_ref().unwrap().to_string(), + TEST_ENC_STRING_4 + ); + assert_eq!( + identity.country.as_ref().unwrap().to_string(), + TEST_ENC_STRING_5 + ); } #[test] @@ -1571,10 +1646,15 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), - data: Some(format!(r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3)), + data: Some(format!( + r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, + TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3 + )), }; - cipher.populate_cipher_types(); + cipher + .populate_cipher_types() + .expect("populate_cipher_types failed"); assert!(cipher.ssh_key.is_some()); let ssh_key = cipher.ssh_key.unwrap(); @@ -1615,13 +1695,13 @@ mod tests { data: None, }; - cipher.populate_cipher_types(); - - // Should not crash and login should be created (empty Login with None fields) - assert!(cipher.login.is_some()); - let login = cipher.login.unwrap(); - assert!(login.username.is_none()); - assert!(login.password.is_none()); + let result = cipher.populate_cipher_types(); + assert!(matches!( + result, + Err(VaultParseError::MissingFieldError(MissingFieldError( + "data" + ))) + )); } #[test] @@ -1656,10 +1736,8 @@ mod tests { data: Some("invalid json".to_string()), }; - cipher.populate_cipher_types(); + let result = cipher.populate_cipher_types(); - // Should not crash and login should remain None - assert!(cipher.login.is_none()); + assert!(matches!(result, Err(VaultParseError::SerdeJson(_)))); } - } diff --git a/crates/bitwarden-vault/src/error.rs b/crates/bitwarden-vault/src/error.rs index b713296ef..3eaeabaf2 100644 --- a/crates/bitwarden-vault/src/error.rs +++ b/crates/bitwarden-vault/src/error.rs @@ -1,6 +1,8 @@ use bitwarden_error::bitwarden_error; use thiserror::Error; +use crate::CipherError; + /// Generic error type for vault encryption errors. #[allow(missing_docs)] #[bitwarden_error(flat)] @@ -29,5 +31,18 @@ pub enum VaultParseError { #[error(transparent)] Crypto(#[from] bitwarden_crypto::CryptoError), #[error(transparent)] - MissingField(#[from] bitwarden_core::MissingFieldError), + MissingFieldError(#[from] bitwarden_core::MissingFieldError), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), +} + +impl From for CipherError { + fn from(e: VaultParseError) -> Self { + match e { + VaultParseError::Crypto(e) => Self::CryptoError(e), + VaultParseError::MissingFieldError(e) => Self::MissingFieldError(e), + VaultParseError::Chrono(e) => Self::Chrono(e), + VaultParseError::SerdeJson(e) => Self::SerdeJson(e), + } + } } From 17aee3ad11600f606f0dea125f149c54705d0267 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 22 Sep 2025 12:19:16 -0700 Subject: [PATCH 08/12] Empty commit to update GH PR From 7b3d95228dc322a7ecf53306a22a34bf9b06d6ae Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 17 Oct 2025 13:01:57 -0700 Subject: [PATCH 09/12] Fix build issues from merge --- .../bitwarden-vault/src/cipher/attachment.rs | 2 ++ crates/bitwarden-vault/src/cipher/cipher.rs | 28 ++++++++++++------- .../src/cipher/cipher_client.rs | 24 +--------------- .../bitwarden-vault/src/cipher/secure_note.rs | 1 + crates/bitwarden-vault/src/error.rs | 6 ++-- 5 files changed, 25 insertions(+), 36 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/attachment.rs b/crates/bitwarden-vault/src/cipher/attachment.rs index aab37e390..30f282d40 100644 --- a/crates/bitwarden-vault/src/cipher/attachment.rs +++ b/crates/bitwarden-vault/src/cipher/attachment.rs @@ -287,6 +287,7 @@ mod tests { deleted_date: None, revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(), archived_date: None, + data: None, }, attachment, contents: contents.as_slice(), @@ -402,6 +403,7 @@ mod tests { deleted_date: None, revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(), archived_date: None, + data: None, }; let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap(); diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 91f109e80..1a6c6dde6 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -110,10 +110,10 @@ pub struct EncryptionContext { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct Cipher { - pub id: Option, - pub organization_id: Option, - pub folder_id: Option, - pub collection_ids: Vec, + pub id: Option, + pub organization_id: Option, + pub folder_id: Option, + pub collection_ids: Vec, /// More recent ciphers uses individual encryption keys to encrypt the other fields of the /// Cipher. pub key: Option, @@ -144,6 +144,7 @@ pub struct Cipher { pub deleted_date: Option>, pub revision_date: DateTime, pub archived_date: Option>, + pub data: Option, } bitwarden_state::register_repository_item!(Cipher, "Cipher"); @@ -344,6 +345,7 @@ impl CompositeEncryptable for CipherView { revision_date: cipher_view.revision_date, permissions: cipher_view.permissions, archived_date: cipher_view.archived_date, + data: None, // TODO: Do we need to repopulate this on this on the cipher? }) } } @@ -455,13 +457,12 @@ impl Cipher { /// This replaces the values provided by the API in the `login`, `secure_note`, `card`, /// `identity`, and `ssh_key` fields, relying instead on client-side parsing of the /// `data` field. + #[allow(unused)] pub(crate) fn populate_cipher_types(&mut self) -> Result<(), VaultParseError> { let data = self .data .as_ref() - .ok_or(VaultParseError::MissingFieldError(MissingFieldError( - "data", - )))?; + .ok_or(VaultParseError::MissingField(MissingFieldError("data")))?; match &self.r#type { crate::CipherType::Login => self.login = serde_json::from_str(data)?, @@ -794,6 +795,7 @@ impl TryFrom for Cipher { revision_date: require!(cipher.revision_date).parse()?, key: EncString::try_from_optional(cipher.key)?, archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?, + data: cipher.data, }) } } @@ -940,6 +942,7 @@ mod tests { deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), archived_date: None, + data: None, }; let view: CipherListView = key_store.decrypt(&cipher).unwrap(); @@ -1407,6 +1410,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some(format!( r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2 @@ -1452,6 +1456,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()), }; @@ -1491,6 +1496,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some(format!( r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, @@ -1554,6 +1560,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some(format!( r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#, TEST_ENC_STRING_1, @@ -1646,6 +1653,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some(format!( r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#, TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3 @@ -1692,15 +1700,14 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: None, }; let result = cipher.populate_cipher_types(); assert!(matches!( result, - Err(VaultParseError::MissingFieldError(MissingFieldError( - "data" - ))) + Err(VaultParseError::MissingField(MissingFieldError("data"))) )); } @@ -1733,6 +1740,7 @@ mod tests { creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + archived_date: None, data: Some("invalid json".to_string()), }; diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client.rs index 725e41765..d00d8d5b6 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client.rs @@ -174,29 +174,6 @@ impl CiphersClient { let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?; Ok(decrypted_key) } - - #[allow(missing_docs)] - fn extract_cipher_types(&self, mut cipher: Cipher) -> Cipher { - let Ok(mut data) = serde_json::to_value(&cipher.data) else { - // If we can't deserialize the data, then we'll return the cipher as-is. - // Should maybe return a result instead? - return cipher; - }; - let _version = data.get("version").and_then(|v| v.as_u64()).unwrap_or(1); - if let Some(data) = data.as_object_mut() { - data.insert("version".to_string(), _version.into()); - } - - match cipher.r#type { - crate::CipherType::Login => cipher.login = serde_json::from_value(data).ok(), - crate::CipherType::SecureNote => cipher.secure_note = serde_json::from_value(data).ok(), - crate::CipherType::Card => cipher.card = serde_json::from_value(data).ok(), - crate::CipherType::Identity => cipher.identity = serde_json::from_value(data).ok(), - crate::CipherType::SshKey => cipher.ssh_key = serde_json::from_value(data).ok(), - } - - cipher - } } #[cfg(test)] @@ -245,6 +222,7 @@ mod tests { deleted_date: None, revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(), archived_date: None, + data: None, } } diff --git a/crates/bitwarden-vault/src/cipher/secure_note.rs b/crates/bitwarden-vault/src/cipher/secure_note.rs index 39e4716ea..97a7d1f82 100644 --- a/crates/bitwarden-vault/src/cipher/secure_note.rs +++ b/crates/bitwarden-vault/src/cipher/secure_note.rs @@ -140,6 +140,7 @@ mod tests { deleted_date: None, revision_date: "2024-01-01T00:00:00.000Z".parse().unwrap(), archived_date: None, + data: None, } } diff --git a/crates/bitwarden-vault/src/error.rs b/crates/bitwarden-vault/src/error.rs index 3eaeabaf2..743cca515 100644 --- a/crates/bitwarden-vault/src/error.rs +++ b/crates/bitwarden-vault/src/error.rs @@ -31,7 +31,7 @@ pub enum VaultParseError { #[error(transparent)] Crypto(#[from] bitwarden_crypto::CryptoError), #[error(transparent)] - MissingFieldError(#[from] bitwarden_core::MissingFieldError), + MissingField(#[from] bitwarden_core::MissingFieldError), #[error(transparent)] SerdeJson(#[from] serde_json::Error), } @@ -39,8 +39,8 @@ pub enum VaultParseError { impl From for CipherError { fn from(e: VaultParseError) -> Self { match e { - VaultParseError::Crypto(e) => Self::CryptoError(e), - VaultParseError::MissingFieldError(e) => Self::MissingFieldError(e), + VaultParseError::Crypto(e) => Self::Crypto(e), + VaultParseError::MissingField(e) => Self::MissingField(e), VaultParseError::Chrono(e) => Self::Chrono(e), VaultParseError::SerdeJson(e) => Self::SerdeJson(e), } From 15558fb7b94e9beb07b850e5f44ec2626855c9c0 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 17 Oct 2025 13:19:25 -0700 Subject: [PATCH 10/12] Cleanup: Remove commented code and move test constants to top of mod --- crates/bitwarden-vault/src/cipher/cipher.rs | 24 ++++++++------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 1a6c6dde6..e87ca8082 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -821,12 +821,6 @@ impl From for CipherRepromptType } } -// impl From<&Cipher> for Option { -// fn from(value: &Cipher) -> Self { - -// } -// } - #[cfg(test)] mod tests { @@ -839,6 +833,15 @@ mod tests { use super::*; use crate::{Fido2Credential, login::Fido2CredentialListView}; + // Test constants for encrypted strings + const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk="; + const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo="; + const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; + const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4="; + const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk="; + const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; + const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69"; + fn generate_cipher() -> CipherView { let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap(); CipherView { @@ -1372,15 +1375,6 @@ mod tests { assert_eq!(decrypted_key_value, "123"); } - // Test constants for encrypted strings - const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk="; - const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo="; - const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; - const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4="; - const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk="; - const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0="; - const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69"; - #[test] fn test_populate_cipher_types_login_with_valid_data() { let mut cipher = Cipher { From 4424d5fe506c057b84b9f150b633a0f43e753c5a Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 22 Oct 2025 14:43:17 -0700 Subject: [PATCH 11/12] Add comment for unused reason on populate_cipher_types --- crates/bitwarden-vault/src/cipher/cipher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index c0a009b04..1b3cc1e60 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -458,7 +458,7 @@ impl Cipher { /// This replaces the values provided by the API in the `login`, `secure_note`, `card`, /// `identity`, and `ssh_key` fields, relying instead on client-side parsing of the /// `data` field. - #[allow(unused)] + #[allow(unused)] // Will be used by future changes to support cipher versioning. pub(crate) fn populate_cipher_types(&mut self) -> Result<(), VaultParseError> { let data = self .data From 02cdfa1a82b3ff754bab0cd06afab2c1c369387c Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 22 Oct 2025 14:44:22 -0700 Subject: [PATCH 12/12] Add field to Cipher struct in missing files --- crates/bitwarden-vault/src/cipher/cipher.rs | 1 + crates/bitwarden-vault/src/cipher/cipher_client/edit.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 1b3cc1e60..7c5ee03c8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -884,6 +884,7 @@ impl TryFrom for Cipher { revision_date: require!(cipher.revision_date).parse()?, key: EncString::try_from_optional(cipher.key)?, archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?, + data: cipher.data, }) } } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 86cd4c34f..6e6e1982f 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -564,6 +564,7 @@ mod tests { deleted_date: None, revision_date: "2024-01-01T00:00:00Z".parse().unwrap(), archived_date: None, + data: None, }, ) .await