diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index fc4bf1355..5d96e396c 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -1,13 +1,10 @@ -use bitwarden_api_api::{ - apis::ciphers_api::{PutShareError, PutShareManyError}, - models::{ - CipherDetailsResponseModel, CipherRequestModel, CipherResponseModel, - CipherWithIdRequestModel, - }, +use bitwarden_api_api::models::{ + CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel, + CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel, }; use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ - MissingFieldError, OrganizationId, UserId, + ApiError, MissingFieldError, OrganizationId, UserId, key_management::{KeyIds, MINIMUM_ENFORCE_ICON_URI_HASH_VERSION, SymmetricKeyId}, require, }; @@ -64,15 +61,19 @@ pub enum CipherError { #[error("This cipher cannot be moved to the specified organization")] OrganizationAlreadySet, #[error(transparent)] - PutShare(#[from] bitwarden_api_api::apis::Error), - #[error(transparent)] - PutShareMany(#[from] bitwarden_api_api::apis::Error), - #[error(transparent)] Repository(#[from] RepositoryError), #[error(transparent)] Chrono(#[from] chrono::ParseError), #[error(transparent)] SerdeJson(#[from] serde_json::Error), + #[error(transparent)] + Api(#[from] ApiError), +} + +impl From> for CipherError { + fn from(value: bitwarden_api_api::apis::Error) -> Self { + Self::Api(value.into()) + } } /// Helper trait for operations on cipher types. @@ -639,6 +640,12 @@ impl Cipher { } Ok(()) } + + /// Marks the cipher as soft deleted by setting `deletion_date` to now. + pub(crate) fn soft_delete(&mut self) { + self.deleted_date = Some(Utc::now()); + self.archived_date = None; + } } impl CipherView { #[allow(missing_docs)] @@ -973,6 +980,15 @@ impl TryFrom for Cipher { } } +impl PartialCipher for CipherDetailsResponseModel { + fn merge_with_cipher(self, cipher: Option) -> Result { + Ok(Cipher { + local_data: cipher.and_then(|c| c.local_data), + ..self.try_into()? + }) + } +} + impl From for CipherType { fn from(t: bitwarden_api_api::models::CipherType) -> Self { match t { @@ -994,6 +1010,13 @@ impl From for CipherRepromptType } } +/// A trait for merging partial cipher data into a full cipher. +/// Used to convert from API response models to full Cipher structs, +/// without losing local data that may not be present in the API response. +pub(crate) trait PartialCipher { + fn merge_with_cipher(self, cipher: Option) -> Result; +} + impl From for bitwarden_api_api::models::CipherType { fn from(t: CipherType) -> Self { match t { @@ -1064,6 +1087,131 @@ impl TryFrom for Cipher { } } +impl PartialCipher for CipherMiniResponseModel { + fn merge_with_cipher(self, cipher: Option) -> Result { + let cipher = cipher.as_ref(); + Ok(Cipher { + id: self.id.map(CipherId::new), + organization_id: self.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(self.key)?, + name: require!(EncString::try_from_optional(self.name)?), + notes: EncString::try_from_optional(self.notes)?, + r#type: require!(self.r#type).into(), + login: self.login.map(|l| (*l).try_into()).transpose()?, + identity: self.identity.map(|i| (*i).try_into()).transpose()?, + card: self.card.map(|c| (*c).try_into()).transpose()?, + secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?, + ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: self + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: self.organization_use_totp.unwrap_or(true), + attachments: self + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: self + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: self + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(self.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: self + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(self.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: self + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + folder_id: cipher.map_or(Default::default(), |c| c.folder_id), + favorite: cipher.map_or(Default::default(), |c| c.favorite), + edit: cipher.map_or(Default::default(), |c| c.edit), + permissions: cipher.map_or(Default::default(), |c| c.permissions), + view_password: cipher.map_or(Default::default(), |c| c.view_password), + local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()), + data: cipher.map_or(Default::default(), |c| c.data.clone()), + collection_ids: cipher.map_or(Default::default(), |c| c.collection_ids.clone()), + }) + } +} + +impl PartialCipher for CipherMiniDetailsResponseModel { + fn merge_with_cipher(self, cipher: Option) -> Result { + let cipher = cipher.as_ref(); + Ok(Cipher { + id: self.id.map(CipherId::new), + organization_id: self.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(self.key)?, + name: require!(EncString::try_from_optional(self.name)?), + notes: EncString::try_from_optional(self.notes)?, + r#type: require!(self.r#type).into(), + login: self.login.map(|l| (*l).try_into()).transpose()?, + identity: self.identity.map(|i| (*i).try_into()).transpose()?, + card: self.card.map(|c| (*c).try_into()).transpose()?, + secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?, + ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: self + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: self.organization_use_totp.unwrap_or(true), + attachments: self + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: self + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: self + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(self.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: self + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(self.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: self + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + collection_ids: self + .collection_ids + .into_iter() + .flatten() + .map(CollectionId::new) + .collect(), + folder_id: cipher.map_or(Default::default(), |c| c.folder_id), + favorite: cipher.map_or(Default::default(), |c| c.favorite), + edit: cipher.map_or(Default::default(), |c| c.edit), + permissions: cipher.map_or(Default::default(), |c| c.permissions), + view_password: cipher.map_or(Default::default(), |c| c.view_password), + data: cipher.map_or(Default::default(), |c| c.data.clone()), + local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()), + }) + } +} + #[cfg(test)] mod tests { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs new file mode 100644 index 000000000..f2f2dac9a --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs @@ -0,0 +1,189 @@ +use bitwarden_api_api::models::CipherCreateRequestModel; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds, +}; +use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{ + Cipher, CipherView, VaultParseError, + cipher::cipher::PartialCipher, + cipher_client::{ + admin::CipherAdminClient, + create::{CipherCreateRequest, CipherCreateRequestInternal, CreateCipherError}, + }, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum CreateCipherAdminError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + NotAuthenticated(#[from] NotAuthenticatedError), +} + +impl From> for CreateCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +/// Wraps the API call to create a cipher using the admin endpoint, for easier testing. +async fn create_cipher( + request: CipherCreateRequestInternal, + encrypted_for: UserId, + api_client: &bitwarden_api_api::apis::ApiClient, + key_store: &KeyStore, +) -> Result { + let collection_ids = request.create_request.collection_ids.clone(); + let mut cipher_request = key_store.encrypt(request)?; + cipher_request.encrypted_for = Some(encrypted_for.into()); + + let cipher: Cipher = api_client + .ciphers_api() + .post_admin(Some(CipherCreateRequestModel { + collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), + cipher: Box::new(cipher_request), + })) + .await? + .merge_with_cipher(None)?; + + Ok(key_store.decrypt(&cipher)?) +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CipherAdminClient { + /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. + /// Creates the Cipher on the server only, does not store it to local state. + pub async fn create( + &self, + request: CipherCreateRequest, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + let mut internal_request: CipherCreateRequestInternal = request.into(); + + let user_id = self + .client + .internal + .get_user_id() + .ok_or(NotAuthenticatedError)?; + + // TODO: Once this flag is removed, the key generation logic should + // be moved closer to the actual encryption logic. + if self + .client + .internal + .get_flags() + .enable_cipher_key_encryption + { + let key = internal_request.key_identifier(); + internal_request.generate_cipher_key(&mut key_store.context(), key)?; + } + + create_cipher(internal_request, user_id, &config.api_client, key_store).await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::models::CipherMiniResponseModel; + use bitwarden_core::{OrganizationId, key_management::SymmetricKeyId}; + use bitwarden_crypto::SymmetricCryptoKey; + use chrono::Utc; + + use super::*; + use crate::{CipherRepromptType, CipherViewType, LoginView}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + #[tokio::test] + async fn test_create_org_cipher() { + let api_client = bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_post_admin() + .returning(move |request| { + let request = request.unwrap(); + + Ok(CipherMiniResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request + .cipher + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request.cipher.name.clone()), + r#type: request.cipher.r#type, + creation_date: Some( + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ), + revision_date: Some( + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ), + ..Default::default() + }) + }); + }); + + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::Organization(TEST_ORG_ID.parse::().unwrap()), + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let cipher_request: CipherCreateRequestInternal = CipherCreateRequest { + organization_id: Some(TEST_ORG_ID.parse().unwrap()), + collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()], + folder_id: None, + name: "Test Cipher".into(), + notes: None, + favorite: false, + reprompt: CipherRepromptType::None, + r#type: CipherViewType::Login(LoginView { + username: None, + password: None, + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + fields: vec![], + } + .into(); + + let response = create_cipher( + cipher_request.clone(), + TEST_USER_ID.parse().unwrap(), + &api_client, + &store, + ) + .await + .unwrap(); + + assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); + assert_eq!( + response.organization_id, + cipher_request.create_request.organization_id + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs new file mode 100644 index 000000000..9b7407763 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs @@ -0,0 +1,226 @@ +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; +use bitwarden_core::{ApiError, OrganizationId}; + +use crate::{CipherId, cipher_client::admin::CipherAdminClient}; + +async fn delete_cipher( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + api.delete_admin(cipher_id.into()).await?; + Ok(()) +} + +async fn delete_ciphers_many( + cipher_ids: Vec, + organization_id: OrganizationId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + + api.delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: Some(organization_id.to_string()), + })) + .await?; + + Ok(()) +} + +async fn soft_delete( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + api.put_delete_admin(cipher_id.into()).await?; + Ok(()) +} + +async fn soft_delete_many( + cipher_ids: Vec, + organization_id: OrganizationId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + + api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: Some(organization_id.to_string()), + })) + .await?; + Ok(()) +} + +impl CipherAdminClient { + /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + /// Affects server data only, does not modify local state. + pub async fn delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + delete_cipher( + cipher_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin + /// endpoint. Affects server data only, does not modify local state. + pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + soft_delete( + cipher_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin + /// endpoint. Affects server data only, does not modify local state. + pub async fn delete_many( + &self, + cipher_ids: Vec, + organization_id: OrganizationId, + ) -> Result<(), ApiError> { + delete_ciphers_many( + cipher_ids, + organization_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin + /// endpoint. Affects server data only, does not modify local state. + pub async fn soft_delete_many( + &self, + cipher_ids: Vec, + organization_id: OrganizationId, + ) -> Result<(), ApiError> { + soft_delete_many( + cipher_ids, + organization_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + #[tokio::test] + async fn test_delete_as_admin() { + delete_cipher( + TEST_CIPHER_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api.expect_delete_admin().returning(move |id| { + assert_eq!(&id.to_string(), TEST_CIPHER_ID); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_soft_delete_as_admin() { + soft_delete( + TEST_CIPHER_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_put_delete_admin() + .returning(move |id| { + assert_eq!(&id.to_string(), TEST_CIPHER_ID); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_delete_many_as_admin() { + delete_ciphers_many( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_delete_many_admin() + .returning(move |request| { + let CipherBulkDeleteRequestModel { + ids, + organization_id, + } = request.unwrap(); + + assert_eq!( + ids, + vec![TEST_CIPHER_ID.to_string(), TEST_CIPHER_ID_2.to_string(),], + ); + assert_eq!(organization_id, Some(TEST_ORG_ID.to_string())); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_soft_delete_many_as_admin() { + soft_delete_many( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_put_delete_many_admin() + .returning(move |request| { + let CipherBulkDeleteRequestModel { + ids, + organization_id, + } = request.unwrap(); + + assert_eq!( + ids, + vec![TEST_CIPHER_ID.to_string(), TEST_CIPHER_ID_2.to_string()], + ); + assert_eq!(organization_id, Some(TEST_ORG_ID.to_string())); + Ok(()) + }); + }), + ) + .await + .unwrap() + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs new file mode 100644 index 000000000..98570eac6 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs @@ -0,0 +1,311 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherCollectionsRequestModel}; +use bitwarden_collections::collection::CollectionId; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds, +}; +use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::RepositoryError; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use super::CipherAdminClient; +use crate::{ + Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError, + cipher::cipher::PartialCipher, + cipher_client::edit::{CipherEditRequest, CipherEditRequestInternal}, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum EditCipherAdminError { + #[error(transparent)] + ItemNotFound(#[from] ItemNotFoundError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + NotAuthenticated(#[from] NotAuthenticatedError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Uuid(#[from] uuid::Error), + #[error(transparent)] + Decrypt(#[from] DecryptError), +} + +impl From> for EditCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +async fn edit_cipher( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + encrypted_for: UserId, + original_cipher_view: CipherView, + request: CipherEditRequest, +) -> Result { + let cipher_id = request.id; + let request = CipherEditRequestInternal::new(request, &original_cipher_view); + + let mut cipher_request = key_store.encrypt(request)?; + cipher_request.encrypted_for = Some(encrypted_for.into()); + + let orig_cipher = key_store.encrypt(original_cipher_view)?; + + let cipher: Cipher = api_client + .ciphers_api() + .put_admin(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .merge_with_cipher(Some(orig_cipher))?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Adds the cipher matched by [CipherId] to any number of collections on the server. +pub async fn add_to_collections( + cipher_id: CipherId, + collection_ids: Vec, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let req = CipherCollectionsRequestModel { + collection_ids: collection_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + }; + + let api = api_client.ciphers_api(); + let cipher: Cipher = api + .put_collections_admin(&cipher_id.to_string(), Some(req)) + .await? + .merge_with_cipher(None)?; + + Ok(key_store.decrypt(&cipher)?) +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CipherAdminClient { + /// Edit an existing [Cipher] and save it to the server. + pub async fn edit( + &self, + mut request: CipherEditRequest, + original_cipher_view: CipherView, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + + let user_id = self + .client + .internal + .get_user_id() + .ok_or(NotAuthenticatedError)?; + + // TODO: Once this flag is removed, the key generation logic should + // be moved closer to the actual encryption logic. + if request.key.is_none() + && self + .client + .internal + .get_flags() + .enable_cipher_key_encryption + { + let key = request.key_identifier(); + request.generate_cipher_key(&mut key_store.context(), key)?; + } + + edit_cipher( + key_store, + &config.api_client, + user_id, + original_cipher_view, + request, + ) + .await + } + + /// Adds the cipher matched by [CipherId] to any number of collections on the server. + pub async fn update_collection( + &self, + cipher_id: CipherId, + collection_ids: Vec, + ) -> Result { + add_to_collections( + cipher_id, + collection_ids, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + &self.client.internal.get_key_store(), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel}; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + + use super::*; + use crate::{CipherId, CipherRepromptType, CipherType, LoginView}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + + fn generate_test_cipher() -> CipherView { + CipherView { + id: Some(TEST_CIPHER_ID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "Test Login".to_string(), + notes: None, + r#type: CipherType::Login, + login: Some(LoginView { + username: Some("test@example.com".to_string()), + password: Some("password123".to_string()), + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: true, + edit: true, + permissions: None, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deleted_date: None, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + archived_date: None, + } + } + + #[tokio::test] + async fn test_edit_cipher() { + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_admin() + .returning(move |_id, body| { + let body = body.unwrap(); + Ok(CipherMiniResponseModel { + object: Some("cipher".to_string()), + id: Some(cipher_id.into()), + name: Some(body.name), + r#type: body.r#type, + organization_id: body + .organization_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + reprompt: body.reprompt, + key: body.key, + notes: body.notes, + organization_use_totp: Some(true), + revision_date: Some("2025-01-01T00:00:00Z".to_string()), + creation_date: Some("2025-01-01T00:00:00Z".to_string()), + deleted_date: None, + login: body.login, + card: body.card, + identity: body.identity, + secure_note: body.secure_note, + ssh_key: body.ssh_key, + fields: body.fields, + password_history: body.password_history, + attachments: None, + data: None, + archived_date: None, + }) + }) + .once(); + }); + + let original_cipher_view = generate_test_cipher(); + let mut cipher_view = original_cipher_view.clone(); + cipher_view.name = "New Cipher Name".to_string(); + + let request: CipherEditRequest = cipher_view.try_into().unwrap(); + + let result = edit_cipher( + &store, + &api_client, + TEST_USER_ID.parse().unwrap(), + original_cipher_view, + request, + ) + .await + .unwrap(); + + assert_eq!(result.id, Some(cipher_id)); + assert_eq!(result.name, "New Cipher Name"); + } + + #[tokio::test] + async fn test_edit_cipher_http_error() { + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_admin() + .returning(move |_id, _body| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + let orig_cipher_view = generate_test_cipher(); + let cipher_view = orig_cipher_view.clone(); + let request: CipherEditRequest = cipher_view.try_into().unwrap(); + let result = edit_cipher( + &store, + &api_client, + TEST_USER_ID.parse().unwrap(), + orig_cipher_view, + request, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_))); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs new file mode 100644 index 000000000..7fd06b1f9 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs @@ -0,0 +1,70 @@ +use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; +use bitwarden_core::{ApiError, OrganizationId, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::{ + VaultParseError, + cipher::cipher::{DecryptCipherListResult, PartialCipher}, + cipher_client::admin::CipherAdminClient, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum GetOrganizationCiphersError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Api(#[from] ApiError), +} + +/// Get all ciphers for an organization. +pub async fn list_org_ciphers( + org_id: OrganizationId, + include_member_items: bool, + api_client: &bitwarden_api_api::apis::ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + let response: CipherMiniDetailsResponseModelListResponseModel = api + .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) + .await + .map_err(ApiError::from)?; + let ciphers = response + .data + .into_iter() + .flatten() + .map(|model| model.merge_with_cipher(None)) + .collect::, _>>()?; + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CipherAdminClient { + pub async fn list_org_ciphers( + &self, + org_id: OrganizationId, + include_member_items: bool, + ) -> Result { + list_org_ciphers( + org_id, + include_member_items, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + self.client.internal.get_key_store(), + ) + .await + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs new file mode 100644 index 000000000..436fa9369 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -0,0 +1,15 @@ +use bitwarden_core::Client; +use wasm_bindgen::prelude::*; + +mod create; +mod delete; +mod edit; +mod get; +mod restore; + +/// Client for performing admin operations on ciphers. Unlike the regular [CiphersClient], +/// this client uses the admin server API endpoints, and does not modify local state. +#[cfg_attr(feature = "wasm", wasm_bindgen)] +pub struct CipherAdminClient { + pub(crate) client: Client, +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs new file mode 100644 index 000000000..b8a3cf603 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs @@ -0,0 +1,291 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; +use bitwarden_core::{ApiError, OrganizationId, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::{ + Cipher, CipherId, CipherView, DecryptCipherListResult, VaultParseError, + cipher::cipher::PartialCipher, cipher_client::admin::CipherAdminClient, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum RestoreCipherAdminError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Crypto(#[from] CryptoError), +} + +impl From> for RestoreCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +/// Restores a soft-deleted cipher on the server, using the admin endpoint. +pub async fn restore_as_admin( + cipher_id: CipherId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let cipher: Cipher = api + .put_restore_admin(cipher_id.into()) + .await? + .merge_with_cipher(None)?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Restores multiple soft-deleted ciphers on the server. +pub async fn restore_many_as_admin( + cipher_ids: Vec, + org_id: OrganizationId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.merge_with_cipher(None)) + .collect::, _>>()?; + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CipherAdminClient { + /// Restores a soft-deleted cipher on the server, using the admin endpoint. + pub async fn restore_as_admin( + &self, + cipher_id: CipherId, + ) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + + restore_as_admin(cipher_id, api_client, key_store).await + } + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many_as_admin( + &self, + cipher_ids: Vec, + org_id: OrganizationId, + ) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + + restore_many_as_admin(cipher_ids, org_id, api_client, key_store).await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{ + apis::ApiClient, + models::{CipherMiniResponseModel, CipherMiniResponseModelListResponseModel}, + }; + use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + use chrono::Utc; + + use super::*; + use crate::{Cipher, CipherId, Login}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Some(Login{ + username: None, + password: None, + password_revision_date: None, + uris: None, totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_restore_as_admin() { + let mut cipher = generate_test_cipher(); + cipher.deleted_date = Some(Utc::now()); + + let api_client = { + let cipher = cipher.clone(); + ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher.name.to_string()), + r#type: Some(cipher.r#type.into()), + creation_date: Some(cipher.creation_date.to_string()), + revision_date: Some(Utc::now().to_rfc3339()), + login: cipher.login.clone().map(|l| Box::new(l.into())), + ..Default::default() + }) + }); + }) + }; + + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + let start_time = Utc::now(); + let updated_cipher = restore_as_admin(TEST_CIPHER_ID.parse().unwrap(), &api_client, &store) + .await + .unwrap(); + let end_time = Utc::now(); + + assert!(updated_cipher.deleted_date.is_none()); + assert!( + updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + } + + #[tokio::test] + async fn test_restore_many_as_admin() { + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + let mut cipher_2 = generate_test_cipher(); + cipher_2.deleted_date = Some(Utc::now()); + cipher_2.id = Some(cipher_id_2); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_many_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_rfc3339()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_rfc3339()), + ..Default::default() + }, + ]), + continuation_token: None, + }) + }); + }); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let start_time = Utc::now(); + let ciphers = restore_many_as_admin( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + &api_client, + &store, + ) + .await + .unwrap(); + let end_time = Utc::now(); + + assert_eq!(ciphers.successes.len(), 2,); + assert_eq!(ciphers.failures.len(), 0,); + assert_eq!( + ciphers.successes[0].id, + Some(TEST_CIPHER_ID.parse().unwrap()), + ); + assert_eq!( + ciphers.successes[1].id, + Some(TEST_CIPHER_ID_2.parse().unwrap()), + ); + assert_eq!(ciphers.successes[0].deleted_date, None,); + assert_eq!(ciphers.successes[1].deleted_date, None,); + + assert!( + ciphers.successes[0].revision_date >= start_time + && ciphers.successes[0].revision_date <= end_time + ); + assert!( + ciphers.successes[1].revision_date >= start_time + && ciphers.successes[1].revision_date <= end_time + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 51e1fa035..53c5144b8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -1,4 +1,5 @@ -use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_api_api::models::{CipherCreateRequestModel, CipherRequestModel}; +use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, key_management::{KeyIds, SymmetricKeyId}, @@ -41,6 +42,12 @@ pub enum CreateCipherError { Repository(#[from] RepositoryError), } +impl From> for CreateCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + /// Request to add a cipher. #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] @@ -48,6 +55,7 @@ pub enum CreateCipherError { #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct CipherCreateRequest { pub organization_id: Option, + pub collection_ids: Vec, pub folder_id: Option, pub name: String, pub notes: Option, @@ -60,8 +68,8 @@ pub struct CipherCreateRequest { /// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted /// value. This allows us to manage the cipher key creation internally. #[derive(Clone, Debug)] -struct CipherCreateRequestInternal { - create_request: CipherCreateRequest, +pub(super) struct CipherCreateRequestInternal { + pub(super) create_request: CipherCreateRequest, key: Option, } @@ -77,7 +85,7 @@ impl From for CipherCreateRequestInternal { impl CipherCreateRequestInternal { /// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the /// encrypted key to the cipher data. - fn generate_cipher_key( + pub(crate) fn generate_cipher_key( &mut self, ctx: &mut KeyStoreContext, key: SymmetricKeyId, @@ -219,25 +227,42 @@ async fn create_cipher + ?Sized>( encrypted_for: UserId, request: CipherCreateRequestInternal, ) -> Result { + let collection_ids = request.create_request.collection_ids.clone(); let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); - let resp = api_client - .ciphers_api() - .post(Some(cipher_request)) - .await - .map_err(ApiError::from)?; - let cipher: Cipher = resp.try_into()?; - repository - .set(require!(cipher.id).to_string(), cipher.clone()) - .await?; + let cipher: Cipher; + if !collection_ids.is_empty() { + cipher = api_client + .ciphers_api() + .post_create(Some(CipherCreateRequestModel { + collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), + cipher: Box::new(cipher_request), + })) + .await + .map_err(ApiError::from)? + .try_into()?; + repository + .set(require!(cipher.id).to_string(), cipher.clone()) + .await?; + } else { + cipher = api_client + .ciphers_api() + .post(Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; + repository + .set(require!(cipher.id).to_string(), cipher.clone()) + .await?; + } + Ok(key_store.decrypt(&cipher)?) } #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { - /// Create a new [Cipher] and save it to the server. - pub async fn create( + async fn create_cipher( &self, request: CipherCreateRequest, ) -> Result { @@ -273,6 +298,14 @@ impl CiphersClient { ) .await } + + /// Creates a new [Cipher] and saves it to the server. + pub async fn create( + &self, + request: CipherCreateRequest, + ) -> Result { + self.create_cipher(request).await + } } #[cfg(test)] @@ -280,12 +313,15 @@ mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; use bitwarden_crypto::SymmetricKeyAlgorithm; use bitwarden_test::MemoryRepository; + use chrono::Utc; use super::*; use crate::{CipherId, LoginView}; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd"; const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; fn generate_test_cipher_create_request() -> CipherCreateRequest { CipherCreateRequest { @@ -305,6 +341,7 @@ mod tests { favorite: Default::default(), reprompt: Default::default(), fields: Default::default(), + collection_ids: vec![], } } @@ -443,4 +480,83 @@ mod tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_))); } + + #[tokio::test] + async fn test_create_org_cipher() { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_post_create() + .returning(move |body| { + let request_body = body.unwrap(); + + Ok(CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request_body + .cipher + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request_body.cipher.name.clone()), + r#type: request_body.cipher.r#type, + creation_date: Some(Utc::now().to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }) + }) + .once(); + }); + + let store: KeyStore = KeyStore::default(); + { + let mut ctx = store.context_mut(); + let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); + ctx.persist_symmetric_key( + local_key_id, + SymmetricKeyId::Organization(TEST_ORG_ID.parse().unwrap()), + ) + .unwrap(); + } + let repository = MemoryRepository::::default(); + let request = CipherCreateRequest { + organization_id: Some(TEST_ORG_ID.parse().unwrap()), + collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()], + folder_id: None, + name: "Test Cipher".into(), + notes: None, + favorite: false, + reprompt: CipherRepromptType::None, + r#type: CipherViewType::Login(LoginView { + username: None, + password: None, + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + fields: vec![], + }; + + let response = create_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request.into(), + ) + .await + .unwrap(); + + let cipher: Cipher = repository + .get(TEST_CIPHER_ID.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_view: CipherView = store.decrypt(&cipher).unwrap(); + + assert_eq!(response.id, cipher_view.id); + assert_eq!(response.organization_id, cipher_view.organization_id); + + assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); + assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); + } } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs new file mode 100644 index 000000000..f9b142796 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -0,0 +1,349 @@ +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; +use bitwarden_core::{ApiError, OrganizationId}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use thiserror::Error; + +use crate::{Cipher, CipherId, CiphersClient}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum DeleteCipherError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + Repository(#[from] RepositoryError), +} + +impl From> for DeleteCipherError { + fn from(value: bitwarden_api_api::apis::Error) -> Self { + Self::Api(value.into()) + } +} + +async fn delete_cipher + ?Sized>( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + api.delete(cipher_id.into()).await?; + repository.remove(cipher_id.to_string()).await?; + Ok(()) +} + +async fn delete_ciphers + ?Sized>( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + + api.delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + + for cipher_id in cipher_ids { + repository.remove(cipher_id.to_string()).await?; + } + Ok(()) +} + +async fn soft_delete + ?Sized>( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + api.put_delete(cipher_id.into()).await?; + process_soft_delete(repository, cipher_id).await?; + Ok(()) +} + +async fn soft_delete_many + ?Sized>( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + + api.put_delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + for cipher_id in cipher_ids { + process_soft_delete(repository, cipher_id).await?; + } + Ok(()) +} + +async fn process_soft_delete + ?Sized>( + repository: &R, + cipher_id: CipherId, +) -> Result<(), RepositoryError> { + let cipher: Option = repository.get(cipher_id.to_string()).await?; + if let Some(mut cipher) = cipher { + cipher.soft_delete(); + repository.set(cipher_id.to_string(), cipher).await?; + } + Ok(()) +} + +impl CiphersClient { + /// Deletes the [Cipher] with the matching [CipherId] from the server. + pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { + let configs = self.client.internal.get_api_configurations().await; + delete_cipher(cipher_id, &configs.api_client, &*self.get_repository()?).await + } + + /// Deletes all [Cipher] objects with a matching [CipherId] from the server. + pub async fn delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), DeleteCipherError> { + let configs = self.client.internal.get_api_configurations().await; + delete_ciphers( + cipher_ids, + organization_id, + &configs.api_client, + &*self.get_repository()?, + ) + .await + } + + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. + pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { + let configs = self.client.internal.get_api_configurations().await; + soft_delete(cipher_id, &configs.api_client, &*self.get_repository()?).await + } + + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server. + pub async fn soft_delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), DeleteCipherError> { + soft_delete_many( + cipher_ids, + organization_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + &*self.get_repository()?, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::apis::ApiClient; + use bitwarden_state::repository::Repository; + use bitwarden_test::MemoryRepository; + use chrono::Utc; + + use crate::{ + Cipher, CipherId, + cipher_client::delete::{delete_cipher, delete_ciphers, soft_delete, soft_delete_many}, + }; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Default::default(), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_delete() { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_delete() + .returning(move |_model| Ok(())); + }); + + // let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let repository = MemoryRepository::::default(); + repository + .set(cipher_id.to_string(), generate_test_cipher()) + .await + .unwrap(); + + delete_cipher(cipher_id, &api_client, &repository) + .await + .unwrap(); + + let cipher = repository.get(cipher_id.to_string()).await.unwrap(); + assert!( + cipher.is_none(), + "Cipher is deleted from the local repository" + ); + } + + #[tokio::test] + async fn test_delete_many() { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_delete_many() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); + + let cipher_1 = generate_test_cipher(); + let mut cipher_2 = generate_test_cipher(); + cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); + + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + + delete_ciphers(vec![cipher_id, cipher_id_2], None, &api_client, &repository) + .await + .unwrap(); + + let cipher_1 = repository.get(cipher_id.to_string()).await.unwrap(); + let cipher_2 = repository.get(cipher_id_2.to_string()).await.unwrap(); + assert!( + cipher_1.is_none(), + "Cipher is deleted from the local repository" + ); + assert!( + cipher_2.is_none(), + "Cipher is deleted from the local repository" + ); + } + + #[tokio::test] + async fn test_soft_delete() { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_delete() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); + + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + repository + .set(cipher_id.to_string(), generate_test_cipher()) + .await + .unwrap(); + + let start_time = Utc::now(); + soft_delete(cipher_id, &api_client, &repository) + .await + .unwrap(); + let end_time = Utc::now(); + + let cipher: Cipher = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + assert!( + cipher.deleted_date.unwrap() >= start_time && cipher.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); + } + + #[tokio::test] + async fn test_soft_delete_many() { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_delete_many() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); + + let cipher_1 = generate_test_cipher(); + let mut cipher_2 = generate_test_cipher(); + cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); + + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + + let start_time = Utc::now(); + + soft_delete_many(vec![cipher_id, cipher_id_2], None, &api_client, &repository) + .await + .unwrap(); + let end_time = Utc::now(); + + let cipher_1 = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_2 = repository + .get(cipher_id_2.to_string()) + .await + .unwrap() + .unwrap(); + + assert!( + cipher_1.deleted_date.unwrap() >= start_time + && cipher_1.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); + assert!( + cipher_2.deleted_date.unwrap() >= start_time + && cipher_2.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 73f522c25..1d6d1be9a 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -1,4 +1,5 @@ -use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_api_api::models::{CipherCollectionsRequestModel, CipherRequestModel}; +use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, key_management::{KeyIds, SymmetricKeyId}, @@ -22,7 +23,8 @@ use super::CiphersClient; use crate::{ AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView, FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, - cipher_view_type::CipherViewType, password_history::MAX_PASSWORD_HISTORY_ENTRIES, + cipher::cipher::PartialCipher, cipher_view_type::CipherViewType, + password_history::MAX_PASSWORD_HISTORY_ENTRIES, }; #[allow(missing_docs)] @@ -47,6 +49,12 @@ pub enum EditCipherError { Uuid(#[from] uuid::Error), } +impl From> for EditCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + /// Request to edit a cipher. #[derive(Clone, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -99,7 +107,7 @@ impl TryFrom for CipherEditRequest { } impl CipherEditRequest { - fn generate_cipher_key( + pub(super) fn generate_cipher_key( &mut self, ctx: &mut KeyStoreContext, key: SymmetricKeyId, @@ -121,14 +129,13 @@ impl CipherEditRequest { /// Used as an intermediary between the public-facing [CipherEditRequest], and the encrypted /// value. This allows us to calculate password history safely, without risking misuse. #[derive(Clone, Debug)] - -struct CipherEditRequestInternal { - edit_request: CipherEditRequest, - password_history: Vec, +pub(super) struct CipherEditRequestInternal { + pub(super) edit_request: CipherEditRequest, + pub(super) password_history: Vec, } impl CipherEditRequestInternal { - fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self { + pub(super) fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self { let mut internal_req = Self { edit_request, password_history: vec![], @@ -286,7 +293,12 @@ impl CompositeEncryptable .transpose()? .map(|c| Box::new(c.into())), - last_known_revision_date: Some(cipher_data.edit_request.revision_date.to_rfc3339()), + last_known_revision_date: Some( + cipher_data + .edit_request + .revision_date + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ), archived_date: cipher_data .edit_request .archived_date @@ -333,16 +345,13 @@ async fn edit_cipher + ?Sized>( let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); - let response = api_client + let cipher: Cipher = api_client .ciphers_api() .put(cipher_id.into(), Some(cipher_request)) .await - .map_err(ApiError::from)?; - - let cipher: Cipher = response.try_into()?; - + .map_err(ApiError::from)? + .try_into()?; debug_assert!(cipher.id.unwrap_or_default() == cipher_id); - repository .set(cipher_id.to_string(), cipher.clone()) .await?; @@ -389,6 +398,42 @@ impl CiphersClient { ) .await } + + /// Adds the cipher matched by [CipherId] to any number of collections on the server. + pub async fn update_collection( + &self, + cipher_id: CipherId, + collection_ids: Vec, + is_admin: bool, + ) -> Result { + let req = CipherCollectionsRequestModel { + collection_ids: collection_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + }; + let repository = self.get_repository()?; + + let api_config = self.client.internal.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + let orig_cipher = repository.get(cipher_id.to_string()).await?; + let cipher = if is_admin { + api.put_collections_admin(&cipher_id.to_string(), Some(req)) + .await? + .merge_with_cipher(orig_cipher)? + } else { + let response: Cipher = api + .put_collections(cipher_id.into(), Some(req)) + .await? + .merge_with_cipher(orig_cipher)?; + repository + .set(cipher_id.to_string(), response.clone()) + .await?; + response + }; + + Ok(self.decrypt(cipher).map_err(|_| CryptoError::KeyDecrypt)?) + } } #[cfg(test)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index 0f7417152..6b4cbd2a4 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -1,11 +1,13 @@ -use bitwarden_core::key_management::KeyIds; +use bitwarden_core::{ApiError, key_management::KeyIds}; use bitwarden_crypto::{CryptoError, KeyStore}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::{Repository, RepositoryError}; use thiserror::Error; use super::CiphersClient; -use crate::{Cipher, CipherView, ItemNotFoundError, cipher::cipher::DecryptCipherListResult}; +use crate::{ + Cipher, CipherView, ItemNotFoundError, VaultParseError, cipher::cipher::DecryptCipherListResult, +}; #[allow(missing_docs)] #[bitwarden_error(flat)] @@ -16,7 +18,11 @@ pub enum GetCipherError { #[error(transparent)] Crypto(#[from] CryptoError), #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] Repository(#[from] RepositoryError), + #[error(transparent)] + Api(#[from] ApiError), } async fn get_cipher( diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 5e9546a6a..52d58b092 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -15,12 +15,15 @@ use super::EncryptionContext; use crate::Fido2CredentialFullView; use crate::{ Cipher, CipherError, CipherListView, CipherView, DecryptError, EncryptError, - cipher::cipher::DecryptCipherListResult, + cipher::cipher::DecryptCipherListResult, cipher_client::admin::CipherAdminClient, }; +mod admin; mod create; +mod delete; mod edit; mod get; +mod restore; mod share_cipher; #[allow(missing_docs)] @@ -184,6 +187,13 @@ impl CiphersClient { let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?; Ok(decrypted_key) } + + /// Returns a new client for performing admin operations. + pub fn admin(&self) -> CipherAdminClient { + CipherAdminClient { + client: self.client.clone(), + } + } } impl CiphersClient { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs new file mode 100644 index 000000000..3d36a6c3f --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -0,0 +1,336 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; +use bitwarden_core::{ApiError, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use thiserror::Error; + +use crate::{ + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, VaultParseError, + cipher::cipher::PartialCipher, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum RestoreCipherError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Crypto(#[from] CryptoError), +} + +impl From> for RestoreCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +/// Restores a soft-deleted cipher on the server. +pub async fn restore + ?Sized>( + cipher_id: CipherId, + api_client: &ApiClient, + repository: &R, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; + repository + .set(cipher_id.to_string(), cipher.clone()) + .await?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Restores multiple soft-deleted ciphers on the server. +pub async fn restore_many + ?Sized>( + cipher_ids: Vec, + api_client: &ApiClient, + repository: &R, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.merge_with_cipher(None)) + .collect::, _>>()?; + + for cipher in &ciphers { + if let Some(id) = &cipher.id { + repository.set(id.to_string(), cipher.clone()).await?; + } + } + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CiphersClient { + /// Restores a soft-deleted cipher on the server. + pub async fn restore(&self, cipher_id: CipherId) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + + restore(cipher_id, api_client, &*self.get_repository()?, key_store).await + } + + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many( + &self, + cipher_ids: Vec, + ) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + let repository = &*self.get_repository()?; + + restore_many(cipher_ids, api_client, repository, key_store).await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{ + apis::ApiClient, + models::{ + CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel, + }, + }; + use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + use bitwarden_state::repository::Repository; + use bitwarden_test::MemoryRepository; + use chrono::Utc; + + use super::*; + use crate::{Cipher, CipherId, Login}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Some(Login{ + username: None, + password: None, + password_revision_date: None, + uris: None, totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_restore() { + // Set up test ciphers in the repository. + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore() + .returning(move |_model| { + Ok(CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + creation_date: Some(cipher_1.creation_date.to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }) + }); + }); + + let repository: MemoryRepository = Default::default(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let mut cipher = generate_test_cipher(); + cipher.deleted_date = Some(Utc::now()); + + repository + .set(TEST_CIPHER_ID.to_string(), cipher) + .await + .unwrap(); + + let start_time = Utc::now(); + let updated_cipher = restore( + TEST_CIPHER_ID.parse().unwrap(), + &api_client, + &repository, + &store, + ) + .await + .unwrap(); + + let end_time = Utc::now(); + assert!(updated_cipher.deleted_date.is_none()); + assert!( + updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + + let repo_cipher = repository + .get(TEST_CIPHER_ID.to_string()) + .await + .unwrap() + .unwrap(); + assert!(repo_cipher.deleted_date.is_none()); + assert!( + repo_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + } + + #[tokio::test] + async fn test_restore_many() { + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + let mut cipher_2 = generate_test_cipher(); + cipher_2.deleted_date = Some(Utc::now()); + cipher_2.id = Some(cipher_id_2); + + let api_client = { + let cipher_1 = cipher_1.clone(); + let cipher_2 = cipher_2.clone(); + ApiClient::new_mocked(move |mock| { + mock.ciphers_api.expect_put_restore_many().returning({ + move |_model| { + Ok(CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }) + } + }); + }) + }; + + let repository: MemoryRepository = Default::default(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + + let start_time = Utc::now(); + let ciphers = restore_many( + vec![cipher_id, cipher_id_2], + &api_client, + &repository, + &store, + ) + .await + .unwrap(); + let end_time = Utc::now(); + + assert_eq!(ciphers.successes.len(), 2,); + assert_eq!(ciphers.failures.len(), 0,); + assert_eq!(ciphers.successes[0].deleted_date, None,); + assert_eq!(ciphers.successes[1].deleted_date, None,); + + // Confirm repository was updated + let cipher_1 = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_2 = repository + .get(cipher_id_2.to_string()) + .await + .unwrap() + .unwrap(); + assert!(cipher_1.deleted_date.is_none()); + assert!(cipher_2.deleted_date.is_none()); + assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time); + assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time); + } +}