diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index fdf60f753..0e4fc9921 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, }; @@ -63,15 +60,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. @@ -636,6 +637,11 @@ impl Cipher { } Ok(()) } + + pub(crate) fn soft_delete(&mut self) { + self.deleted_date = Some(Utc::now()); + self.archived_date = None; + } } impl CipherView { #[allow(missing_docs)] @@ -1061,6 +1067,138 @@ impl TryFrom for Cipher { } } +impl TryFrom for Cipher { + type Error = VaultParseError; + fn try_from(cipher_mini: CipherMiniResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + edit: Default::default(), + favorite: Default::default(), + folder_id: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + collection_ids: Default::default(), + data: None, + }) + } +} + +impl TryFrom for Cipher { + type Error = VaultParseError; + + fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + collection_ids: cipher_mini + .collection_ids + .into_iter() + .flatten() + .map(CollectionId::new) + .collect(), + data: None, + folder_id: Default::default(), + favorite: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + }) + } +} + #[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..5f2305156 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs @@ -0,0 +1,193 @@ +use bitwarden_api_api::models::CipherCreateRequestModel; +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 thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{ + Cipher, CipherView, VaultParseError, + 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, + collection_ids: Vec, + encrypted_for: UserId, + api_client: &bitwarden_api_api::apis::ApiClient, + key_store: &KeyStore, +) -> Result { + 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? + .try_into()?; + + 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, + collection_ids: Vec, + ) -> 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, + collection_ids, + 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_string()), + revision_date: Some(Utc::now().to_string()), + ..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().into()), + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let cipher_request: CipherCreateRequestInternal = CipherCreateRequest { + organization_id: Some(TEST_ORG_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(), + vec![TEST_COLLECTION_ID.parse().unwrap()], + 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..14e7a75e4 --- /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: Option, + 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: organization_id.map(|id| 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: Option, + 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: organization_id.map(|id| 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: Option, + ) -> 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: Option, + ) -> 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().ok(), + &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().ok(), + &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..ce5886b6c --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs @@ -0,0 +1,308 @@ +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_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 cipher: Cipher = api_client + .ciphers_api() + .put_admin(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; + + 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? + .try_into()?; + + 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..1c8772e8f --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs @@ -0,0 +1,69 @@ +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::{ + Cipher, VaultParseError, cipher::cipher::DecryptCipherListResult, + 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(Cipher::try_from) + .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..0eb51896e --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -0,0 +1,14 @@ +use bitwarden_core::Client; +use wasm_bindgen::prelude::*; + +mod create; +mod delete; +mod edit; +mod get; +mod restore; + +#[allow(missing_docs)] +#[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..1eb81343e --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs @@ -0,0 +1,288 @@ +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_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?.try_into()?; + + 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.try_into()) + .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_string()), + 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_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 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..4959a0e99 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")] @@ -60,8 +67,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 create_request: CipherCreateRequest, key: Option, } @@ -77,7 +84,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, @@ -218,28 +225,46 @@ async fn create_cipher + ?Sized>( repository: &R, encrypted_for: UserId, request: CipherCreateRequestInternal, + collection_ids: Vec, ) -> Result { 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, + collection_ids: Vec, ) -> Result { let key_store = self.client.internal.get_key_store(); let config = self.client.internal.get_api_configurations().await; @@ -270,22 +295,54 @@ impl CiphersClient { repository.as_ref(), user_id, internal_request, + collection_ids, ) .await } + + /// Creates a new [Cipher] and saves it to the server. + pub async fn create( + &self, + request: CipherCreateRequest, + ) -> Result { + self.create_cipher(request, vec![]).await + } + + /// Creates a new [Cipher] for an organization, and saves it to the server. + pub async fn create_org_cipher( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + self.create_cipher(request, collection_ids).await + } } #[cfg(test)] mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_core::{ + Client, ClientSettings, DeviceType, UserId, + key_management::{account_cryptographic_state::WrappedAccountCryptographicState, crypto::{ + InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, + }}, + }; + use bitwarden_crypto::{EncString, Kdf}; + use bitwarden_test::{MemoryRepository, start_api_mock}; + use chrono::Utc; + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path}, + }; use bitwarden_crypto::SymmetricKeyAlgorithm; - use bitwarden_test::MemoryRepository; use super::*; - use crate::{CipherId, LoginView}; + use crate::{CipherId, LoginView, VaultClientExt}; 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 { @@ -308,6 +365,65 @@ mod tests { } } + async fn create_client_with_wiremock(mock_server: &wiremock::MockServer) -> CiphersClient { + let settings = ClientSettings { + identity_url: format!("http://{}", mock_server.address()), + api_url: format!("http://{}", mock_server.address()), + user_agent: "Bitwarden Test".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + client + .internal + .load_flags(std::collections::HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + + let user_request = InitUserCryptoRequest { + user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), + kdf_params: Kdf::PBKDF2 { + iterations: 600_000.try_into().unwrap(), + }, + email: "test@bitwarden.com".to_owned(), + method: InitUserCryptoMethod::Password { + password: "asdfasdfasdf".to_owned(), + user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), + }, + account_cryptographic_state: WrappedAccountCryptographicState::V1 { + private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), + } + }; + + let org_request = InitOrgCryptoRequest { + organization_keys: std::collections::HashMap::from([( + TEST_ORG_ID.parse().unwrap(), + "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() + )]) + }; + + client + .crypto() + .initialize_user_crypto(user_request) + .await + .unwrap(); + client + .crypto() + .initialize_org_crypto(org_request) + .await + .unwrap(); + + client + .platform() + .state() + .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); + + client.vault().ciphers() + } + #[tokio::test] async fn test_create_cipher() { let store: KeyStore = KeyStore::default(); @@ -373,6 +489,7 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request.into(), + vec![], ) .await .unwrap(); @@ -437,10 +554,80 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request.into(), + vec![], ) .await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_))); } + + #[tokio::test] + async fn test_create_org_cipher() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("POST")) + .and(path(r"/ciphers/create")) + .respond_with(move |req: &wiremock::Request| { + let body_bytes = req.body.as_slice(); + let request_body: CipherCreateRequestModel = + serde_json::from_slice(body_bytes).expect("Failed to parse request body"); + + let response = 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() + }; + + ResponseTemplate::new(200).set_body_json(&response) + }), + ]) + .await; + + let client = create_client_with_wiremock(&mock_server).await; + let response = client + .create_org_cipher( + CipherCreateRequest { + organization_id: Some(TEST_ORG_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![], + }, + vec![TEST_COLLECTION_ID.parse().unwrap()], + ) + .await + .unwrap(); + + let repository = client.get_repository().unwrap(); + let cipher: Cipher = repository + .get(TEST_CIPHER_ID.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_view = client.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..21c76677d --- /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( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> 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( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> 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( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> 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( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> 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( + repository: &(impl Repository + ?Sized), + 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..853b49dd6 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}, @@ -20,8 +21,8 @@ use wasm_bindgen::prelude::*; use super::CiphersClient; use crate::{ - AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView, - FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, + AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, DecryptError, + FieldView, FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, cipher_view_type::CipherViewType, password_history::MAX_PASSWORD_HISTORY_ENTRIES, }; @@ -45,6 +46,14 @@ pub enum EditCipherError { Repository(#[from] RepositoryError), #[error(transparent)] Uuid(#[from] uuid::Error), + #[error(transparent)] + Decrypt(#[from] DecryptError), +} + +impl From> for EditCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } } /// Request to edit a cipher. @@ -99,7 +108,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 +130,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![], @@ -333,16 +341,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 +394,40 @@ 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 api_config = self.client.internal.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + let cipher = if is_admin { + api.put_collections_admin(&cipher_id.to_string(), Some(req)) + .await? + .try_into()? + } else { + let response: Cipher = api + .put_collections(cipher_id.into(), Some(req)) + .await? + .try_into()?; + self.get_repository()? + .set(cipher_id.to_string(), response.clone()) + .await?; + response + }; + + Ok(self.decrypt(cipher)?) + } } #[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 dfe222a02..fe051ddb8 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)] RepositoryError(#[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..6d6a12eae 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -18,9 +18,12 @@ use crate::{ cipher::cipher::DecryptCipherListResult, }; +mod admin; mod create; +mod delete; mod edit; mod get; +mod restore; mod share_cipher; #[allow(missing_docs)] 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..cb15a91c4 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -0,0 +1,344 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; +use bitwarden_core::{ + ApiError, + key_management::{KeyIds, SymmetricKeyId}, +}; +use bitwarden_crypto::{CryptoError, KeyStore, SymmetricCryptoKey}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use thiserror::Error; + +use crate::{ + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, VaultParseError, +}; + +#[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( + cipher_id: CipherId, + api_client: &ApiClient, + repository: &(impl Repository + ?Sized), + 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( + cipher_ids: Vec, + api_client: &ApiClient, + repository: &(impl Repository + ?Sized), + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + 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.try_into()) + .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); + } +}