diff --git a/crates/bitwarden/src/client/test_accounts.rs b/crates/bitwarden/src/client/test_accounts.rs index 83d157c74..22927d7d8 100644 --- a/crates/bitwarden/src/client/test_accounts.rs +++ b/crates/bitwarden/src/client/test_accounts.rs @@ -14,6 +14,11 @@ impl Client { pub async fn init_test_account(account: TestAccount) -> Self { let mut client = Client::new(None); + client.load_flags(HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + initialize_user_crypto(&mut client, account.user) .await .unwrap(); diff --git a/crates/bitwarden/src/mobile/vault/client_ciphers.rs b/crates/bitwarden/src/mobile/vault/client_ciphers.rs index b0760702f..0127179da 100644 --- a/crates/bitwarden/src/mobile/vault/client_ciphers.rs +++ b/crates/bitwarden/src/mobile/vault/client_ciphers.rs @@ -82,7 +82,7 @@ mod tests { use super::*; use crate::{ client::test_accounts::test_bitwarden_com_account, - vault::{login::Login, CipherRepromptType, CipherType}, + vault::{login::Login, Attachment, CipherRepromptType, CipherType}, }; #[tokio::test] @@ -126,4 +126,221 @@ mod tests { assert_eq!(dec[0].name, "Test item"); } + + fn test_cipher() -> Cipher { + Cipher { + id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: Some(Login{ + username: None, + password: None, + password_revision_date: None, + uris:None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: None, + card: None, + secure_note: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: true, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(), + } + } + + fn test_attachment_legacy() -> Attachment { + Attachment { + id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()), + url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()), + file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()), + key: None, + size: Some("65".to_string()), + size_name: Some("65 Bytes".to_string()), + } + } + + fn test_attachment_v2() -> Attachment { + Attachment { + id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()), + url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()), + file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()), + key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()), + size: Some("65".to_string()), + size_name: Some("65 Bytes".to_string()), + } + } + + #[tokio::test] + async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() { + let mut client = Client::init_test_account(test_bitwarden_com_account()).await; + + let mut cipher = test_cipher(); + cipher.attachments = Some(vec![test_attachment_legacy()]); + + let view = client + .vault() + .ciphers() + .decrypt(cipher.clone()) + .await + .unwrap(); + + // Move cipher to organization + let res = client + .vault() + .ciphers() + .move_to_organization( + view, + "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(), + ) + .await; + + assert!(res.is_err()); + } + + #[tokio::test] + async fn test_encrypt_cipher_with_legacy_attachment_without_key() { + let mut client = Client::init_test_account(test_bitwarden_com_account()).await; + + let mut cipher = test_cipher(); + let attachment = test_attachment_legacy(); + cipher.attachments = Some(vec![attachment.clone()]); + + let view = client + .vault() + .ciphers() + .decrypt(cipher.clone()) + .await + .unwrap(); + + assert!(cipher.key.is_none()); + + // Assert the cipher has a key, and the attachment is still readable + let new_cipher = client.vault().ciphers().encrypt(view).await.unwrap(); + assert!(new_cipher.key.is_some()); + + let view = client.vault().ciphers().decrypt(new_cipher).await.unwrap(); + let attachments = view.clone().attachments.unwrap(); + let attachment_view = attachments.first().unwrap().clone(); + assert!(attachment_view.key.is_none()); + + assert_eq!(attachment_view.file_name.unwrap(), "h.txt"); + + let buf = vec![ + 2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117, + 41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23, + 211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215, + 43, 10, 244, 107, 226, 152, 85, 167, 66, 185, + ]; + + let content = client + .vault() + .attachments() + .decrypt_buffer(cipher, attachment, buf.as_slice()) + .await + .unwrap(); + + assert_eq!(content, b"Hello"); + } + + #[tokio::test] + async fn test_encrypt_cipher_with_v1_attachment_without_key() { + let mut client = Client::init_test_account(test_bitwarden_com_account()).await; + + let mut cipher = test_cipher(); + let attachment = test_attachment_v2(); + cipher.attachments = Some(vec![attachment.clone()]); + + let view = client + .vault() + .ciphers() + .decrypt(cipher.clone()) + .await + .unwrap(); + + assert!(cipher.key.is_none()); + + // Assert the cipher has a key, and the attachment is still readable + let new_cipher = client.vault().ciphers().encrypt(view).await.unwrap(); + assert!(new_cipher.key.is_some()); + + let view = client.vault().ciphers().decrypt(new_cipher).await.unwrap(); + let attachments = view.clone().attachments.unwrap(); + let attachment_view = attachments.first().unwrap().clone(); + assert!(attachment_view.key.is_some()); + + // Ensure attachment key is updated since it's now protected by the cipher key + assert_ne!( + attachment.clone().key.unwrap().to_string(), + attachment_view.clone().key.unwrap().to_string() + ); + + assert_eq!(attachment_view.file_name.unwrap(), "h.txt"); + + let buf = vec![ + 2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46, + 189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28, + 158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95, + 188, 196, 170, 127, 67, 44, 129, 90, + ]; + + let content = client + .vault() + .attachments() + .decrypt_buffer(cipher, attachment, buf.as_slice()) + .await + .unwrap(); + + assert_eq!(content, b"Hello"); + + // Move cipher to organization + let new_view = client + .vault() + .ciphers() + .move_to_organization( + view, + "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(), + ) + .await + .unwrap(); + let new_cipher = client.vault().ciphers().encrypt(new_view).await.unwrap(); + + let attachment = new_cipher + .clone() + .attachments + .unwrap() + .first() + .unwrap() + .clone(); + + // Ensure attachment key is still the same since it's protected by the cipher key + assert_eq!( + attachment.clone().key.unwrap().to_string(), + attachment_view.key.unwrap().to_string() + ); + + let content = client + .vault() + .attachments() + .decrypt_buffer(new_cipher, attachment, buf.as_slice()) + .await + .unwrap(); + + assert_eq!(content, b"Hello"); + } } diff --git a/crates/bitwarden/src/vault/cipher/attachment.rs b/crates/bitwarden/src/vault/cipher/attachment.rs index 6545b253f..99d4e270f 100644 --- a/crates/bitwarden/src/vault/cipher/attachment.rs +++ b/crates/bitwarden/src/vault/cipher/attachment.rs @@ -43,6 +43,11 @@ pub struct AttachmentEncryptResult { pub struct AttachmentFile { pub cipher: Cipher, pub attachment: Attachment, + + /// There are three different ways attachments are encrypted. + /// 1. UserKey / OrgKey (Contents) - Legacy + /// 2. AttachmentKey(Contents) - Pre CipherKey + /// 3. CipherKey(AttachmentKey(Contents)) - Current pub contents: EncString, } @@ -80,15 +85,16 @@ impl KeyDecryptable> for AttachmentFile { let ciphers_key = Cipher::get_cipher_key(key, &self.cipher.key)?; let ciphers_key = ciphers_key.as_ref().unwrap_or(key); - let mut attachment_key: Vec = self - .attachment - .key - .as_ref() - .ok_or(CryptoError::MissingKey)? - .decrypt_with_key(ciphers_key)?; - let attachment_key = SymmetricCryptoKey::try_from(attachment_key.as_mut_slice())?; + // Version 2 or 3, `AttachmentKey` or `CipherKey(AttachmentKey)` + if let Some(attachment_key) = &self.attachment.key { + let mut content_key: Vec = attachment_key.decrypt_with_key(ciphers_key)?; + let content_key = SymmetricCryptoKey::try_from(content_key.as_mut_slice())?; - self.contents.decrypt_with_key(&attachment_key) + self.contents.decrypt_with_key(&content_key) + } else { + // Legacy attachment version 1, use user/org key + self.contents.decrypt_with_key(key) + } } } @@ -145,7 +151,7 @@ mod tests { #[test] fn test_attachment_key() { - let user_key : SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap(); + let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap(); let attachment = Attachment { id: None, @@ -196,4 +202,58 @@ mod tests { assert_eq!(dec, original); } + + #[test] + fn test_attachment_without_key() { + let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap(); + + let attachment = Attachment { + id: None, + url: None, + size: Some("161".into()), + size_name: Some("161 Bytes".into()), + file_name: Some("2.qTJdrgQe+tLCHTlPHMJXLw==|0we9+uYJPEY3FU5SblX2hg==|+fL/wTF/WgpoV+BNzmsmi284O0QNdVBUYmfqqs0CG1E=".parse().unwrap()), + key: None, + }; + + let cipher = Cipher { + id: None, + organization_id: None, + folder_id: None, + collection_ids: Vec::new(), + key: None, + name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(), + deleted_date: None, + revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(), + }; + + let enc_file = STANDARD.decode(b"AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap(); + let original = STANDARD.decode(b"rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap(); + + let dec = AttachmentFile { + cipher, + attachment, + contents: EncString::from_buffer(&enc_file).unwrap(), + } + .decrypt_with_key(&user_key) + .unwrap(); + + assert_eq!(dec, original); + } } diff --git a/crates/bitwarden/src/vault/cipher/cipher.rs b/crates/bitwarden/src/vault/cipher/cipher.rs index 4b469bb1d..15a1c124c 100644 --- a/crates/bitwarden/src/vault/cipher/cipher.rs +++ b/crates/bitwarden/src/vault/cipher/cipher.rs @@ -343,11 +343,13 @@ fn build_subtitle_identity(first_name: Option, last_name: Option impl CipherView { pub fn generate_cipher_key(&mut self, key: &SymmetricCryptoKey) -> Result<()> { - let ciphers_key = Cipher::get_cipher_key(key, &self.key)?; - let key = ciphers_key.as_ref().unwrap_or(key); + let old_ciphers_key = Cipher::get_cipher_key(key, &self.key)?; + let old_key = old_ciphers_key.as_ref().unwrap_or(key); let new_key = SymmetricCryptoKey::generate(rand::thread_rng()); + self.reencrypt_attachment_keys(old_key, &new_key)?; + self.key = Some(new_key.to_vec().encrypt_with_key(key)?); Ok(()) } @@ -366,23 +368,47 @@ impl CipherView { } } + fn reencrypt_attachment_keys( + &mut self, + old_key: &SymmetricCryptoKey, + new_key: &SymmetricCryptoKey, + ) -> Result<()> { + if let Some(attachments) = &mut self.attachments { + for attachment in attachments { + if let Some(attachment_key) = &mut attachment.key { + let dec_attachment_key: Vec = attachment_key.decrypt_with_key(old_key)?; + *attachment_key = dec_attachment_key.encrypt_with_key(new_key)?; + } + } + } + Ok(()) + } + pub fn move_to_organization( &mut self, enc: &dyn KeyContainer, organization_id: Uuid, ) -> Result<()> { - // If the cipher has a key, we need to re-encrypt it with the new organization key - if let Some(cipher_key) = &mut self.key { - let old_key = enc - .get_key(&self.organization_id) - .ok_or(Error::VaultLocked)?; + let old_key = enc + .get_key(&self.organization_id) + .ok_or(Error::VaultLocked)?; + + let new_key = enc + .get_key(&Some(organization_id)) + .ok_or(Error::VaultLocked)?; - let new_key = enc - .get_key(&Some(organization_id)) - .ok_or(Error::VaultLocked)?; + // If any attachment is missing a key we can't reencrypt the attachment keys + if self.attachments.iter().flatten().any(|a| a.key.is_none()) { + return Err("This cipher contains attachments without keys. Those attachments will need to be reuploaded to complete the operation".into()); + } + // If the cipher has a key, we need to re-encrypt it with the new organization key + if let Some(cipher_key) = &mut self.key { let dec_cipher_key: Vec = cipher_key.decrypt_with_key(old_key)?; *cipher_key = dec_cipher_key.encrypt_with_key(new_key)?; + } else { + // If the cipher does not have a key, we need to reencrypt all attachment keys + self.reencrypt_attachment_keys(old_key, new_key)?; } self.organization_id = Some(organization_id); @@ -545,6 +571,8 @@ mod tests { use std::collections::HashMap; + use attachment::AttachmentView; + use super::*; fn generate_cipher() -> CipherView { @@ -607,6 +635,41 @@ mod tests { assert_eq!(key_cipher_dec.name, original_cipher.name); } + #[test] + fn test_generate_cipher_key_when_a_cipher_key_already_exists() { + let key = SymmetricCryptoKey::generate(rand::thread_rng()); + + let cipher_key = SymmetricCryptoKey::generate(rand::thread_rng()); + let cipher_key = cipher_key.to_vec().encrypt_with_key(&key).unwrap(); + + let mut original_cipher = generate_cipher(); + original_cipher.key = Some(cipher_key.clone()); + + original_cipher.generate_cipher_key(&key).unwrap(); + + // Make sure that the cipher key is decryptable + let _: Vec = original_cipher.key.unwrap().decrypt_with_key(&key).unwrap(); + } + + #[test] + fn test_generate_cipher_key_ignores_attachments_without_key() { + let key = SymmetricCryptoKey::generate(rand::thread_rng()); + + let mut cipher = generate_cipher(); + let attachment = AttachmentView { + id: None, + url: None, + size: None, + size_name: None, + file_name: Some("Attachment test name".into()), + key: None, + }; + cipher.attachments = Some(vec![attachment]); + + cipher.generate_cipher_key(&key).unwrap(); + assert!(cipher.attachments.unwrap()[0].key.is_none()); + } + struct MockKeyContainer(HashMap, SymmetricCryptoKey>); impl KeyContainer for MockKeyContainer { fn get_key<'a>(&'a self, org_id: &Option) -> Option<&'a SymmetricCryptoKey> { @@ -663,6 +726,131 @@ mod tests { assert!(cipher.encrypt_with_key(org_key).is_err()); } + #[test] + fn test_move_user_cipher_with_attachment_without_key_to_org() { + let org = uuid::Uuid::new_v4(); + + let enc = MockKeyContainer(HashMap::from([ + (None, SymmetricCryptoKey::generate(rand::thread_rng())), + (Some(org), SymmetricCryptoKey::generate(rand::thread_rng())), + ])); + + let mut cipher = generate_cipher(); + let attachment = AttachmentView { + id: None, + url: None, + size: None, + size_name: None, + file_name: Some("Attachment test name".into()), + key: None, + }; + cipher.attachments = Some(vec![attachment]); + + // Neither cipher nor attachment have keys, so the cipher can't be moved + assert!(cipher.move_to_organization(&enc, org).is_err()); + } + + #[test] + fn test_move_user_cipher_with_attachment_with_key_to_org() { + let org = uuid::Uuid::new_v4(); + + let enc = MockKeyContainer(HashMap::from([ + (None, SymmetricCryptoKey::generate(rand::thread_rng())), + (Some(org), SymmetricCryptoKey::generate(rand::thread_rng())), + ])); + + // Attachment has a key that is encrypted with the user key, as the cipher has no key itself + let attachment_key = SymmetricCryptoKey::generate(rand::thread_rng()); + let attachment_key_enc = attachment_key + .to_vec() + .encrypt_with_key(enc.get_key(&None).unwrap()) + .unwrap(); + + let mut cipher = generate_cipher(); + let attachment = AttachmentView { + id: None, + url: None, + size: None, + size_name: None, + file_name: Some("Attachment test name".into()), + key: Some(attachment_key_enc), + }; + cipher.attachments = Some(vec![attachment]); + + cipher.move_to_organization(&enc, org).unwrap(); + + assert!(cipher.key.is_none()); + + // Check that the attachment key has been re-encrypted with the org key, + // and the value matches with the original attachment key + let new_attachment_key = cipher.attachments.unwrap()[0].key.clone().unwrap(); + let new_attachment_key_dec: Vec<_> = new_attachment_key + .decrypt_with_key(enc.get_key(&Some(org)).unwrap()) + .unwrap(); + let new_attachment_key_dec: SymmetricCryptoKey = new_attachment_key_dec.try_into().unwrap(); + assert_eq!(new_attachment_key_dec.to_vec(), attachment_key.to_vec()); + } + + #[test] + fn test_move_user_cipher_with_key_with_attachment_with_key_to_org() { + let org = uuid::Uuid::new_v4(); + + let enc = MockKeyContainer(HashMap::from([ + (None, SymmetricCryptoKey::generate(rand::thread_rng())), + (Some(org), SymmetricCryptoKey::generate(rand::thread_rng())), + ])); + + let cipher_key = SymmetricCryptoKey::generate(rand::thread_rng()); + let cipher_key_enc = cipher_key + .to_vec() + .encrypt_with_key(enc.get_key(&None).unwrap()) + .unwrap(); + + // Attachment has a key that is encrypted with the cipher key + let attachment_key = SymmetricCryptoKey::generate(rand::thread_rng()); + let attachment_key_enc = attachment_key + .to_vec() + .encrypt_with_key(&cipher_key) + .unwrap(); + + let mut cipher = generate_cipher(); + cipher.key = Some(cipher_key_enc); + + let attachment = AttachmentView { + id: None, + url: None, + size: None, + size_name: None, + file_name: Some("Attachment test name".into()), + key: Some(attachment_key_enc.clone()), + }; + cipher.attachments = Some(vec![attachment]); + + cipher.move_to_organization(&enc, org).unwrap(); + + // Check that the cipher key has been re-encrypted with the org key, + let new_cipher_key_dec: Vec<_> = cipher + .key + .clone() + .unwrap() + .decrypt_with_key(enc.get_key(&Some(org)).unwrap()) + .unwrap(); + + let new_cipher_key_dec: SymmetricCryptoKey = new_cipher_key_dec.try_into().unwrap(); + + assert_eq!(new_cipher_key_dec.to_vec(), cipher_key.to_vec()); + + // Check that the attachment key hasn't changed + assert_eq!( + cipher.attachments.unwrap()[0] + .key + .as_ref() + .unwrap() + .to_string(), + attachment_key_enc.to_string() + ); + } + #[test] fn test_build_subtitle_card_visa() { let brand = Some("Visa".to_owned());