diff --git a/crates/bitwarden-vault/src/cipher/attachment.rs b/crates/bitwarden-vault/src/cipher/attachment.rs index ba8c9bb5d..01b28d786 100644 --- a/crates/bitwarden-vault/src/cipher/attachment.rs +++ b/crates/bitwarden-vault/src/cipher/attachment.rs @@ -1,3 +1,4 @@ +use bitwarden_api_api::models::CipherAttachmentModel; use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; use bitwarden_crypto::{ CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext, @@ -25,6 +26,15 @@ pub struct Attachment { pub key: Option, } +impl From for CipherAttachmentModel { + fn from(attachment: Attachment) -> Self { + Self { + file_name: attachment.file_name.map(|f| f.to_string()), + key: attachment.key.map(|k| k.to_string()), + } + } +} + #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -51,6 +61,34 @@ pub struct AttachmentView { pub decrypted_key: Option, } +impl AttachmentView { + pub(crate) fn reencrypt_key( + &mut self, + ctx: &mut KeyStoreContext, + old_key: SymmetricKeyId, + new_key: SymmetricKeyId, + ) -> Result<(), CryptoError> { + if let Some(attachment_key) = &mut self.key { + let tmp_attachment_key_id = SymmetricKeyId::Local("attachment_key"); + ctx.unwrap_symmetric_key(old_key, tmp_attachment_key_id, attachment_key)?; + *attachment_key = ctx.wrap_symmetric_key(new_key, tmp_attachment_key_id)?; + } + Ok(()) + } + + pub(crate) fn reencrypt_keys( + attachment_views: &mut Vec, + ctx: &mut KeyStoreContext, + old_key: SymmetricKeyId, + new_key: SymmetricKeyId, + ) -> Result<(), CryptoError> { + for attachment in attachment_views { + attachment.reencrypt_key(ctx, old_key, new_key)?; + } + Ok(()) + } +} + #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/bitwarden-vault/src/cipher/card.rs b/crates/bitwarden-vault/src/cipher/card.rs index 6c0507502..0b88ec8c9 100644 --- a/crates/bitwarden-vault/src/cipher/card.rs +++ b/crates/bitwarden-vault/src/cipher/card.rs @@ -127,6 +127,19 @@ impl TryFrom for Card { } } +impl From for bitwarden_api_api::models::CipherCardModel { + fn from(card: Card) -> Self { + Self { + cardholder_name: card.cardholder_name.map(|n| n.to_string()), + brand: card.brand.map(|b| b.to_string()), + number: card.number.map(|n| n.to_string()), + exp_month: card.exp_month.map(|m| m.to_string()), + exp_year: card.exp_year.map(|y| y.to_string()), + code: card.code.map(|c| c.to_string()), + } + } +} + impl CipherKind for Card { fn decrypt_subtitle( &self, diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9c6b36173..b7594e4df 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -1,4 +1,4 @@ -use bitwarden_api_api::models::CipherDetailsResponseModel; +use bitwarden_api_api::models::{CipherDetailsResponseModel, CipherResponseModel}; use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ MissingFieldError, OrganizationId, UserId, @@ -30,8 +30,8 @@ use super::{ secure_note, ssh_key, }; use crate::{ - EncryptError, Fido2CredentialFullView, Fido2CredentialView, FolderId, Login, LoginView, - VaultParseError, password_history, + AttachmentView, EncryptError, Fido2CredentialFullView, Fido2CredentialView, FolderId, Login, + LoginView, VaultParseError, password_history, }; uuid_newtype!(pub CipherId); @@ -79,11 +79,12 @@ pub enum CipherType { } #[allow(missing_docs)] -#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)] +#[derive(Clone, Copy, Default, Serialize_repr, Deserialize_repr, Debug, PartialEq)] #[repr(u8)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub enum CipherRepromptType { + #[default] None = 0, Password = 1, } @@ -472,10 +473,8 @@ impl CipherView { #[allow(missing_docs)] pub fn generate_checksums(&mut self) { - if let Some(uris) = self.login.as_mut().and_then(|l| l.uris.as_mut()) { - for uri in uris { - uri.generate_checksum(); - } + if let Some(l) = self.login.as_mut() { + l.generate_checksums(); } } @@ -493,13 +492,7 @@ impl CipherView { new_key: SymmetricKeyId, ) -> Result<(), CryptoError> { if let Some(attachments) = &mut self.attachments { - for attachment in attachments { - if let Some(attachment_key) = &mut attachment.key { - let tmp_attachment_key_id = SymmetricKeyId::Local("attachment_key"); - ctx.unwrap_symmetric_key(old_key, tmp_attachment_key_id, attachment_key)?; - *attachment_key = ctx.wrap_symmetric_key(new_key, tmp_attachment_key_id)?; - } - } + AttachmentView::reencrypt_keys(attachments, ctx, old_key, new_key)?; } Ok(()) } @@ -528,11 +521,7 @@ impl CipherView { new_key: SymmetricKeyId, ) -> Result<(), CryptoError> { if let Some(login) = self.login.as_mut() { - if let Some(fido2_credentials) = &mut login.fido2_credentials { - let dec_fido2_credentials: Vec = - fido2_credentials.decrypt(ctx, old_key)?; - *fido2_credentials = dec_fido2_credentials.encrypt_composite(ctx, new_key)?; - } + login.reencrypt_fido2_credentials(ctx, old_key, new_key)?; } Ok(()) } @@ -695,6 +684,15 @@ impl Decryptable for Cipher { } } +#[cfg(feature = "wasm")] +impl wasm_bindgen::__rt::VectorIntoJsValue for CipherView { + fn vector_into_jsvalue( + vector: wasm_bindgen::__rt::std::boxed::Box<[Self]>, + ) -> wasm_bindgen::JsValue { + wasm_bindgen::__rt::js_value_vector_into_jsvalue(vector) + } +} + impl IdentifyKey for Cipher { fn key_identifier(&self) -> SymmetricKeyId { match self.organization_id { @@ -796,6 +794,75 @@ impl From for CipherRepromptType } } +impl From for bitwarden_api_api::models::CipherType { + fn from(t: CipherType) -> Self { + match t { + CipherType::Login => bitwarden_api_api::models::CipherType::Login, + CipherType::SecureNote => bitwarden_api_api::models::CipherType::SecureNote, + CipherType::Card => bitwarden_api_api::models::CipherType::Card, + CipherType::Identity => bitwarden_api_api::models::CipherType::Identity, + CipherType::SshKey => bitwarden_api_api::models::CipherType::SSHKey, + } + } +} + +impl From for bitwarden_api_api::models::CipherRepromptType { + fn from(t: CipherRepromptType) -> Self { + match t { + CipherRepromptType::None => bitwarden_api_api::models::CipherRepromptType::None, + CipherRepromptType::Password => bitwarden_api_api::models::CipherRepromptType::Password, + } + } +} + +impl TryFrom for Cipher { + type Error = VaultParseError; + + fn try_from(cipher: CipherResponseModel) -> Result { + Ok(Self { + id: cipher.id.map(CipherId::new), + organization_id: cipher.organization_id.map(OrganizationId::new), + folder_id: cipher.folder_id.map(FolderId::new), + collection_ids: vec![], // CipherResponseModel doesn't include collection_ids + name: require!(cipher.name).parse()?, + notes: EncString::try_from_optional(cipher.notes)?, + r#type: require!(cipher.r#type).into(), + login: cipher.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher.secure_note.map(|s| (*s).try_into()).transpose()?, + ssh_key: cipher.ssh_key.map(|s| (*s).try_into()).transpose()?, + favorite: cipher.favorite.unwrap_or(false), + reprompt: cipher + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher.organization_use_totp.unwrap_or(false), + edit: cipher.edit.unwrap_or(false), + permissions: cipher.permissions.map(|p| (*p).try_into()).transpose()?, + view_password: cipher.view_password.unwrap_or(true), + local_data: None, // Not sent from server + attachments: cipher + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher.creation_date).parse()?, + deleted_date: cipher.deleted_date.map(|d| d.parse()).transpose()?, + revision_date: require!(cipher.revision_date).parse()?, + key: EncString::try_from_optional(cipher.key)?, + archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?, + }) + } +} + #[cfg(test)] mod tests { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs new file mode 100644 index 000000000..e42430fee --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -0,0 +1,446 @@ +use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, + key_management::{KeyIds, SymmetricKeyId}, + require, +}; +use bitwarden_crypto::{ + CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext, + PrimitiveEncryptable, +}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use tsify::Tsify; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use super::CiphersClient; +use crate::{ + Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError, + cipher_view_type::CipherViewType, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum CreateCipherError { + #[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), +} + +/// Request to add a cipher. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct CipherCreateRequest { + pub organization_id: Option, + pub folder_id: Option, + pub name: String, + pub notes: Option, + pub favorite: bool, + pub reprompt: CipherRepromptType, + pub r#type: CipherViewType, + pub fields: Vec, +} + +/// 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, + key: Option, +} + +impl From for CipherCreateRequestInternal { + fn from(create_request: CipherCreateRequest) -> Self { + Self { + create_request, + key: None, + } + } +} + +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( + &mut self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result<(), CryptoError> { + let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?; + + const NEW_KEY_ID: SymmetricKeyId = SymmetricKeyId::Local("new_cipher_key"); + + let new_key = ctx.generate_symmetric_key(NEW_KEY_ID)?; + self.create_request + .r#type + .as_login_view_mut() + .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key)) + .transpose()?; + + self.key = Some(ctx.wrap_symmetric_key(key, new_key)?); + Ok(()) + } + + fn generate_checksums(&mut self) { + if let Some(login) = &mut self.create_request.r#type.as_login_view_mut() { + login.generate_checksums(); + } + } +} + +impl CompositeEncryptable + for CipherCreateRequestInternal +{ + fn encrypt_composite( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result { + // Clone self so we can generating the checksums before encrypting. + let mut cipher_data = (*self).clone(); + cipher_data.generate_checksums(); + + let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &cipher_data.key)?; + + let cipher_request = CipherRequestModel { + encrypted_for: None, + r#type: Some(cipher_data.create_request.r#type.get_cipher_type().into()), + organization_id: cipher_data + .create_request + .organization_id + .map(|id| id.to_string()), + folder_id: cipher_data + .create_request + .folder_id + .map(|id| id.to_string()), + favorite: Some(cipher_data.create_request.favorite), + reprompt: Some(cipher_data.create_request.reprompt.into()), + key: cipher_data.key.map(|k| k.to_string()), + name: cipher_data + .create_request + .name + .encrypt(ctx, cipher_key)? + .to_string(), + notes: cipher_data + .create_request + .notes + .as_ref() + .map(|n| n.encrypt(ctx, cipher_key)) + .transpose()? + .map(|n| n.to_string()), + login: cipher_data + .create_request + .r#type + .as_login_view() + .as_ref() + .map(|l| l.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|l| Box::new(l.into())), + card: cipher_data + .create_request + .r#type + .as_card_view() + .as_ref() + .map(|c| c.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|c| Box::new(c.into())), + identity: cipher_data + .create_request + .r#type + .as_identity_view() + .as_ref() + .map(|i| i.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|i| Box::new(i.into())), + secure_note: cipher_data + .create_request + .r#type + .as_secure_note_view() + .as_ref() + .map(|s| s.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|s| Box::new(s.into())), + ssh_key: cipher_data + .create_request + .r#type + .as_ssh_key_view() + .as_ref() + .map(|s| s.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|s| Box::new(s.into())), + fields: Some( + cipher_data + .create_request + .fields + .iter() + .map(|f| f.encrypt_composite(ctx, cipher_key)) + .map(|f| f.map(|f| f.into())) + .collect::, _>>()?, + ), + password_history: None, + attachments: None, + attachments2: None, + last_known_revision_date: None, + archived_date: None, + data: None, + }; + + Ok(cipher_request) + } +} + +impl IdentifyKey for CipherCreateRequestInternal { + fn key_identifier(&self) -> SymmetricKeyId { + match self.create_request.organization_id { + Some(organization_id) => SymmetricKeyId::Organization(organization_id), + None => SymmetricKeyId::User, + } + } +} + +async fn create_cipher + ?Sized>( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, + encrypted_for: UserId, + request: CipherCreateRequestInternal, +) -> 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?; + 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( + &self, + request: CipherCreateRequest, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + let repository = self.get_repository()?; + 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( + key_store, + &config.api_client, + repository.as_ref(), + user_id, + internal_request, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_crypto::SymmetricCryptoKey; + use bitwarden_test::MemoryRepository; + + use super::*; + use crate::{CipherId, 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_create_request() -> CipherCreateRequest { + CipherCreateRequest { + name: "Test Login".to_string(), + notes: Some("Test notes".to_string()), + r#type: CipherViewType::Login(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, + }), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + } + } + + #[tokio::test] + async fn test_create_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_post() + .returning(move |body| { + let body = body.unwrap(); + Ok(CipherResponseModel { + object: Some("cipher".to_string()), + id: Some(cipher_id.into()), + name: Some(body.name.clone()), + r#type: body.r#type, + organization_id: body + .organization_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + folder_id: body + .folder_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + favorite: body.favorite, + reprompt: body.reprompt, + key: body.key.clone(), + notes: body.notes.clone(), + view_password: Some(true), + edit: Some(true), + 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, + permissions: None, + data: None, + archived_date: None, + }) + }) + .once(); + }); + + let repository = MemoryRepository::::default(); + let request = generate_test_cipher_create_request(); + + let result = create_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request.into(), + ) + .await + .unwrap(); + + assert_eq!(result.id, Some(cipher_id)); + assert_eq!(result.name, "Test Login"); + assert_eq!( + result.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, + }) + ); + + // Confirm the cipher was stored in the repository + let stored_cipher_view: CipherView = store + .decrypt( + &repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(stored_cipher_view.id, result.id); + assert_eq!(stored_cipher_view.name, result.name); + assert_eq!(stored_cipher_view.r#type, result.r#type); + assert!(stored_cipher_view.login.is_some()); + assert_eq!(stored_cipher_view.favorite, result.favorite); + } + + #[tokio::test] + async fn test_create_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_post().returning(move |_body| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + + let repository = MemoryRepository::::default(); + + let request = generate_test_cipher_create_request(); + + let result = create_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request.into(), + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_))); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs new file mode 100644 index 000000000..86cd4c34f --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -0,0 +1,839 @@ +use std::collections::HashMap; + +use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, + key_management::{KeyIds, SymmetricKeyId}, + require, +}; +use bitwarden_crypto::{ + CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext, + PrimitiveEncryptable, +}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::{Repository, RepositoryError}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use tsify::Tsify; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use super::CiphersClient; +use crate::{ + AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldType, + FieldView, FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, + cipher_view_type::CipherViewType, +}; + +/// Maximum number of password history entries to retain +const MAX_PASSWORD_HISTORY_ENTRIES: usize = 5; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum EditCipherError { + #[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), +} + +/// Request to edit a cipher. +#[derive(Clone, Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct CipherEditRequest { + pub id: CipherId, + + pub organization_id: Option, + pub folder_id: Option, + pub favorite: bool, + pub reprompt: CipherRepromptType, + pub name: String, + pub notes: Option, + pub fields: Vec, + pub r#type: CipherViewType, + pub revision_date: DateTime, + pub archived_date: Option>, + pub attachments: Vec, + pub key: Option, +} + +impl TryFrom for CipherEditRequest { + type Error = MissingFieldError; + + fn try_from(value: CipherView) -> Result { + let type_data = match value.r#type { + CipherType::Login => value.login.map(CipherViewType::Login), + CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote), + CipherType::Card => value.card.map(CipherViewType::Card), + CipherType::Identity => value.identity.map(CipherViewType::Identity), + CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey), + }; + Ok(Self { + id: value.id.ok_or(MissingFieldError("id"))?, + organization_id: value.organization_id, + folder_id: value.folder_id, + favorite: value.favorite, + reprompt: value.reprompt, + key: value.key, + name: value.name, + notes: value.notes, + fields: value.fields.unwrap_or_default(), + r#type: require!(type_data), + attachments: value.attachments.unwrap_or_default(), + revision_date: value.revision_date, + archived_date: value.archived_date, + }) + } +} + +impl CipherEditRequest { + fn generate_cipher_key( + &mut self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result<(), CryptoError> { + let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?; + + const NEW_KEY_ID: SymmetricKeyId = SymmetricKeyId::Local("new_cipher_key"); + let new_key = ctx.generate_symmetric_key(NEW_KEY_ID)?; + + // Re-encrypt the internal fields with the new key + self.r#type + .as_login_view_mut() + .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key)) + .transpose()?; + AttachmentView::reencrypt_keys(&mut self.attachments, ctx, old_key, new_key)?; + Ok(()) + } +} + +/// 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, +} + +impl CipherEditRequestInternal { + fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self { + let mut internal_req = Self { + edit_request, + password_history: vec![], + }; + internal_req.update_password_history(orig_cipher); + + internal_req + } + + fn update_password_history(&mut self, original_cipher: &CipherView) { + let changes = self + .detect_login_password_changes(original_cipher) + .into_iter() + .chain(self.detect_hidden_field_changes(original_cipher)); + let history: Vec<_> = changes + .rev() + .chain(original_cipher.password_history.iter().flatten().cloned()) + .take(MAX_PASSWORD_HISTORY_ENTRIES) + .collect(); + + self.password_history = history; + } + + fn detect_login_password_changes( + &mut self, + original_cipher: &CipherView, + ) -> Vec { + if !matches!(self.edit_request.r#type, CipherViewType::Login(_)) + || original_cipher.r#type != CipherType::Login + { + return vec![]; + } + + let (Some(original_login), Some(current_login)) = ( + original_cipher.login.as_ref(), + self.edit_request.r#type.as_login_view_mut(), + ) else { + return vec![]; + }; + + let original_password = original_login.password.as_deref().unwrap_or(""); + let current_password = current_login.password.as_deref().unwrap_or(""); + + if original_password.is_empty() { + // No original password - set revision date only if adding new password + if !current_password.is_empty() { + current_login.password_revision_date = Some(Utc::now()); + } + vec![] + } else if original_password == current_password { + // Password unchanged - preserve original revision date + current_login.password_revision_date = original_login.password_revision_date; + vec![] + } else { + // Password changed - update revision date and track change + current_login.password_revision_date = Some(Utc::now()); + vec![PasswordHistoryView::new_password(original_password)] + } + } + + fn detect_hidden_field_changes( + &self, + original_cipher: &CipherView, + ) -> Vec { + let original_fields = + Self::extract_hidden_fields(original_cipher.fields.as_deref().unwrap_or_default()); + let current_fields = Self::extract_hidden_fields(&self.edit_request.fields); + + original_fields + .into_iter() + .filter_map(|(field_name, original_value)| { + let current_value = current_fields.get(&field_name); + if current_value != Some(&original_value) { + Some(PasswordHistoryView::new_field(&field_name, &original_value)) + } else { + None + } + }) + .collect() + } + + fn extract_hidden_fields(fields: &[FieldView]) -> HashMap { + fields + .iter() + .filter_map(|f| match (&f.r#type, &f.name, &f.value) { + (FieldType::Hidden, Some(name), Some(value)) + if !name.is_empty() && !value.is_empty() => + { + Some((name.clone(), value.clone())) + } + _ => None, + }) + .collect() + } + + fn generate_checksums(&mut self) { + if let Some(login) = &mut self.edit_request.r#type.as_login_view_mut() { + login.generate_checksums(); + } + } +} + +impl CompositeEncryptable + for CipherEditRequestInternal +{ + fn encrypt_composite( + &self, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, + ) -> Result { + let mut cipher_data = (*self).clone(); + cipher_data.generate_checksums(); + + let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &self.edit_request.key)?; + + let cipher_request = CipherRequestModel { + encrypted_for: None, + r#type: Some(cipher_data.edit_request.r#type.get_cipher_type().into()), + organization_id: cipher_data + .edit_request + .organization_id + .map(|id| id.to_string()), + folder_id: cipher_data.edit_request.folder_id.map(|id| id.to_string()), + favorite: Some(cipher_data.edit_request.favorite), + reprompt: Some(cipher_data.edit_request.reprompt.into()), + key: cipher_data.edit_request.key.map(|k| k.to_string()), + name: cipher_data + .edit_request + .name + .encrypt(ctx, cipher_key)? + .to_string(), + notes: cipher_data + .edit_request + .notes + .as_ref() + .map(|n| n.encrypt(ctx, cipher_key)) + .transpose()? + .map(|n| n.to_string()), + fields: Some( + cipher_data + .edit_request + .fields + .encrypt_composite(ctx, cipher_key)? + .into_iter() + .map(|f| f.into()) + .collect(), + ), + password_history: Some( + cipher_data + .password_history + .encrypt_composite(ctx, cipher_key)? + .into_iter() + .map(Into::into) + .collect(), + ), + attachments: None, + attachments2: Some( + cipher_data + .edit_request + .attachments + .encrypt_composite(ctx, cipher_key)? + .into_iter() + .map(|a| { + Ok(( + a.id.clone().ok_or(CryptoError::MissingField("id"))?, + a.into(), + )) as Result<_, CryptoError> + }) + .collect::>()?, + ), + login: cipher_data + .edit_request + .r#type + .as_login_view() + .map(|l| l.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|l| Box::new(l.into())), + card: cipher_data + .edit_request + .r#type + .as_card_view() + .map(|c| c.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|c| Box::new(c.into())), + identity: cipher_data + .edit_request + .r#type + .as_identity_view() + .map(|i| i.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|c| Box::new(c.into())), + + secure_note: cipher_data + .edit_request + .r#type + .as_secure_note_view() + .map(|i| i.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|c| Box::new(c.into())), + ssh_key: cipher_data + .edit_request + .r#type + .as_ssh_key_view() + .map(|i| i.encrypt_composite(ctx, cipher_key)) + .transpose()? + .map(|c| Box::new(c.into())), + + last_known_revision_date: Some(cipher_data.edit_request.revision_date.to_rfc3339()), + archived_date: cipher_data + .edit_request + .archived_date + .map(|d| d.to_rfc3339()), + data: None, + }; + + Ok(cipher_request) + } +} + +impl IdentifyKey for CipherEditRequest { + fn key_identifier(&self) -> SymmetricKeyId { + match self.organization_id { + Some(organization_id) => SymmetricKeyId::Organization(organization_id), + None => SymmetricKeyId::User, + } + } +} + +impl IdentifyKey for CipherEditRequestInternal { + fn key_identifier(&self) -> SymmetricKeyId { + self.edit_request.key_identifier() + } +} + +async fn edit_cipher + ?Sized>( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &R, + encrypted_for: UserId, + request: CipherEditRequest, +) -> Result { + let cipher_id = request.id; + + let original_cipher = repository + .get(cipher_id.to_string()) + .await? + .ok_or(ItemNotFoundError)?; + let original_cipher_view: CipherView = key_store.decrypt(&original_cipher)?; + + 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 response = api_client + .ciphers_api() + .put(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)?; + + let cipher: Cipher = response.try_into()?; + + debug_assert!(cipher.id.unwrap_or_default() == cipher_id); + + repository + .set(cipher_id.to_string(), cipher.clone()) + .await?; + + Ok(key_store.decrypt(&cipher)?) +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CiphersClient { + /// Edit an existing [Cipher] and save it to the server. + pub async fn edit( + &self, + mut request: CipherEditRequest, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + let repository = self.get_repository()?; + + 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, + repository.as_ref(), + user_id, + request, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricCryptoKey}; + use bitwarden_test::MemoryRepository; + use chrono::TimeZone; + + use super::*; + use crate::{ + Cipher, CipherId, CipherRepromptType, CipherType, Login, LoginView, PasswordHistoryView, + }; + + 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, + } + } + + fn create_test_login_cipher(password: &str) -> CipherView { + let mut cipher_view = generate_test_cipher(); + if let Some(ref mut login) = cipher_view.login { + login.password = Some(password.to_string()); + } + cipher_view + } + + async fn repository_add_cipher( + repository: &MemoryRepository, + store: &KeyStore, + cipher_id: CipherId, + name: &str, + ) { + let mut ctx = store.context(); + + repository + .set( + cipher_id.to_string(), + Cipher { + id: Some(cipher_id), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: name.encrypt(&mut ctx, SymmetricKeyId::User).unwrap(), + notes: None, + r#type: CipherType::Login, + login: Some(Login { + username: Some("test@example.com") + .map(|u| u.encrypt(&mut ctx, SymmetricKeyId::User)) + .transpose() + .unwrap(), + password: Some("password123") + .map(|p| p.encrypt(&mut ctx, SymmetricKeyId::User)) + .transpose() + .unwrap(), + 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: "2024-01-01T00:00:00Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-01T00:00:00Z".parse().unwrap(), + archived_date: None, + }, + ) + .await + .unwrap(); + } + + #[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() + .returning(move |_id, body| { + let body = body.unwrap(); + Ok(CipherResponseModel { + 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()), + folder_id: body + .folder_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + favorite: body.favorite, + reprompt: body.reprompt, + key: body.key, + notes: body.notes, + view_password: Some(true), + edit: Some(true), + 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, + permissions: None, + data: None, + archived_date: None, + }) + }) + .once(); + }); + + let repository = MemoryRepository::::default(); + repository_add_cipher(&repository, &store, cipher_id, "old_name").await; + let cipher_view = generate_test_cipher(); + + let request = cipher_view.try_into().unwrap(); + + let result = edit_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request, + ) + .await + .unwrap(); + + assert_eq!(result.id, Some(cipher_id)); + assert_eq!(result.name, "Test Login"); + } + + #[tokio::test] + async fn test_edit_cipher_does_not_exist() { + let store: KeyStore = KeyStore::default(); + + let repository = MemoryRepository::::default(); + + let cipher_view = generate_test_cipher(); + let api_client = ApiClient::new_mocked(|_| {}); + + let request = cipher_view.try_into().unwrap(); + + let result = edit_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EditCipherError::ItemNotFound(_) + )); + } + + #[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 cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap(); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api.expect_put().returning(move |_id, _body| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + + let repository = MemoryRepository::::default(); + repository_add_cipher(&repository, &store, cipher_id, "old_name").await; + let cipher_view = generate_test_cipher(); + + let request = cipher_view.try_into().unwrap(); + + let result = edit_cipher( + &store, + &api_client, + &repository, + TEST_USER_ID.parse().unwrap(), + request, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), EditCipherError::Api(_))); + } + + #[test] + fn test_password_history_on_password_change() { + let original_cipher = create_test_login_cipher("old_password"); + let edit_request = + CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap(); + + let start = Utc::now(); + let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher); + let history = internal_req.password_history; + let end = Utc::now(); + + assert_eq!(history.len(), 1); + assert!( + history[0].last_used_date >= start && history[0].last_used_date <= end, + "last_used_date was not set properly" + ); + assert_eq!(history[0].password, "old_password"); + } + + #[test] + fn test_password_history_on_unchanged_password() { + let original_cipher = create_test_login_cipher("same_password"); + let edit_request = + CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap(); + + let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher); + let password_history = internal_req.password_history; + + assert!(password_history.is_empty()); + } + + #[test] + fn test_password_history_is_preserved() { + let mut original_cipher = create_test_login_cipher("same_password"); + original_cipher.password_history = Some( + (0..4) + .map(|i| PasswordHistoryView { + password: format!("old_password_{}", i), + last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(), + }) + .collect(), + ); + + let edit_request = + CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap(); + let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher); + let history = internal_req.password_history; + + assert_eq!(history[0].password, "old_password_0"); + + assert_eq!( + history[0].last_used_date, + Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap() + ); + assert_eq!(history[1].password, "old_password_1"); + assert_eq!( + history[1].last_used_date, + Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap() + ); + assert_eq!(history[2].password, "old_password_2"); + assert_eq!( + history[2].last_used_date, + Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap() + ); + assert_eq!(history[3].password, "old_password_3"); + assert_eq!( + history[3].last_used_date, + Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap() + ); + } + + #[test] + fn test_password_history_with_hidden_fields() { + let mut original_cipher = create_test_login_cipher("password"); + original_cipher.fields = Some(vec![FieldView { + name: Some("Secret Key".to_string()), + value: Some("old_secret_value".to_string()), + r#type: FieldType::Hidden, + linked_id: None, + }]); + + let mut new_cipher = create_test_login_cipher("password"); + new_cipher.fields = Some(vec![FieldView { + name: Some("Secret Key".to_string()), + value: Some("new_secret_value".to_string()), + r#type: FieldType::Hidden, + linked_id: None, + }]); + + let edit_request = CipherEditRequest::try_from(new_cipher).unwrap(); + + let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher); + let history = internal_req.password_history; + + assert_eq!(history.len(), 1); + assert_eq!(history[0].password, "Secret Key: old_secret_value"); + } + + #[test] + fn test_password_history_length_limit() { + let mut original_cipher = create_test_login_cipher("password"); + original_cipher.password_history = Some( + (0..10) + .map(|i| PasswordHistoryView { + password: format!("old_password_{}", i), + last_used_date: Utc::now(), + }) + .collect(), + ); + + // Create edit request with new password (no existing history) + let edit_request = + CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap(); + + let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher); + let history = internal_req.password_history; + + assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES); + // Most recent change (original password) should be first + assert_eq!(history[0].password, "password"); + + assert_eq!(history[1].password, "old_password_0"); + assert_eq!(history[2].password, "old_password_1"); + assert_eq!(history[3].password, "old_password_2"); + assert_eq!(history[4].password, "old_password_3"); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs new file mode 100644 index 000000000..dfe222a02 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -0,0 +1,65 @@ +use bitwarden_core::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}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum GetCipherError { + #[error(transparent)] + ItemNotFound(#[from] ItemNotFoundError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + RepositoryError(#[from] RepositoryError), +} + +async fn get_cipher( + store: &KeyStore, + repository: &dyn Repository, + id: &str, +) -> Result { + let cipher = repository + .get(id.to_string()) + .await? + .ok_or(ItemNotFoundError)?; + + Ok(store.decrypt(&cipher)?) +} + +async fn list_ciphers( + store: &KeyStore, + repository: &dyn Repository, +) -> Result { + let ciphers = repository.list().await?; + let (successes, failures) = store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CiphersClient { + /// Get all ciphers from state and decrypt them, returning both successes and failures. + /// This method will not fail when some ciphers fail to decrypt, allowing for graceful + /// handling of corrupted or problematic cipher data. + pub async fn list(&self) -> Result { + let key_store = self.client.internal.get_key_store(); + let repository = self.get_repository()?; + + list_ciphers(key_store, repository.as_ref()).await + } + + /// Get [Cipher] by ID from state and decrypt it to a [CipherView]. + pub async fn get(&self, cipher_id: &str) -> Result { + let key_store = self.client.internal.get_key_store(); + let repository = self.get_repository()?; + + get_cipher(key_store, repository.as_ref(), cipher_id).await + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs similarity index 98% rename from crates/bitwarden-vault/src/cipher/cipher_client.rs rename to crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 060e1bcfd..baa0d90fa 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -1,7 +1,10 @@ +use std::sync::Arc; + use bitwarden_core::{Client, OrganizationId, key_management::SymmetricKeyId}; use bitwarden_crypto::{CompositeEncryptable, IdentifyKey, SymmetricCryptoKey}; #[cfg(feature = "wasm")] use bitwarden_encoding::B64; +use bitwarden_state::repository::{Repository, RepositoryError}; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -11,6 +14,10 @@ use crate::{ Fido2CredentialFullView, cipher::cipher::DecryptCipherListResult, }; +mod create; +mod edit; +mod get; + #[allow(missing_docs)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct CiphersClient { @@ -176,6 +183,16 @@ impl CiphersClient { } } +impl CiphersClient { + fn get_repository(&self) -> Result>, RepositoryError> { + Ok(self + .client + .platform() + .state() + .get_client_managed::()?) + } +} + #[cfg(test)] mod tests { diff --git a/crates/bitwarden-vault/src/cipher/cipher_view_type.rs b/crates/bitwarden-vault/src/cipher/cipher_view_type.rs new file mode 100644 index 000000000..53e622784 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_view_type.rs @@ -0,0 +1,104 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +use crate::{CardView, IdentityView, LoginView, SecureNoteView, SshKeyView}; + +/// Represents the inner data of a cipher view. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[allow(missing_docs, clippy::large_enum_variant)] +pub enum CipherViewType { + Login(LoginView), + Card(CardView), + Identity(IdentityView), + SecureNote(SecureNoteView), + SshKey(SshKeyView), +} + +impl CipherViewType { + /// Returns the corresponding [crate::CipherType] for this view type. + pub fn get_cipher_type(&self) -> crate::CipherType { + match self { + CipherViewType::Login(_) => crate::CipherType::Login, + CipherViewType::Card(_) => crate::CipherType::Card, + CipherViewType::Identity(_) => crate::CipherType::Identity, + CipherViewType::SecureNote(_) => crate::CipherType::SecureNote, + CipherViewType::SshKey(_) => crate::CipherType::SshKey, + } + } +} + +#[allow(unused)] +impl CipherViewType { + pub(crate) fn as_login_view_mut(&mut self) -> Option<&mut LoginView> { + match self { + CipherViewType::Login(l) => Some(l), + _ => None, + } + } + + pub(crate) fn as_card_view_mut(&mut self) -> Option<&mut CardView> { + match self { + CipherViewType::Card(c) => Some(c), + _ => None, + } + } + + pub(crate) fn as_identity_view_mut(&mut self) -> Option<&mut IdentityView> { + match self { + CipherViewType::Identity(i) => Some(i), + _ => None, + } + } + + pub(crate) fn as_secure_note_view_mut(&mut self) -> Option<&mut SecureNoteView> { + match self { + CipherViewType::SecureNote(s) => Some(s), + _ => None, + } + } + + pub(crate) fn as_ssh_key_view_mut(&mut self) -> Option<&mut SshKeyView> { + match self { + CipherViewType::SshKey(s) => Some(s), + _ => None, + } + } + pub(crate) fn as_login_view(&self) -> Option<&LoginView> { + match self { + CipherViewType::Login(l) => Some(l), + _ => None, + } + } + + pub(crate) fn as_card_view(&self) -> Option<&CardView> { + match self { + CipherViewType::Card(c) => Some(c), + _ => None, + } + } + + pub(crate) fn as_identity_view(&self) -> Option<&IdentityView> { + match self { + CipherViewType::Identity(i) => Some(i), + _ => None, + } + } + + pub(crate) fn as_secure_note_view(&self) -> Option<&SecureNoteView> { + match self { + CipherViewType::SecureNote(s) => Some(s), + _ => None, + } + } + + pub(crate) fn as_ssh_key_view(&self) -> Option<&SshKeyView> { + match self { + CipherViewType::SshKey(s) => Some(s), + _ => None, + } + } +} diff --git a/crates/bitwarden-vault/src/cipher/field.rs b/crates/bitwarden-vault/src/cipher/field.rs index 2434f57af..e16f15e46 100644 --- a/crates/bitwarden-vault/src/cipher/field.rs +++ b/crates/bitwarden-vault/src/cipher/field.rs @@ -130,6 +130,28 @@ impl From for FieldType { } } +impl From for bitwarden_api_api::models::CipherFieldModel { + fn from(field: Field) -> Self { + Self { + name: field.name.map(|n| n.to_string()), + value: field.value.map(|v| v.to_string()), + r#type: Some(field.r#type.into()), + linked_id: field.linked_id.map(|id| u32::from(id) as i32), + } + } +} + +impl From for bitwarden_api_api::models::FieldType { + fn from(field_type: FieldType) -> Self { + match field_type { + FieldType::Text => bitwarden_api_api::models::FieldType::Text, + FieldType::Hidden => bitwarden_api_api::models::FieldType::Hidden, + FieldType::Boolean => bitwarden_api_api::models::FieldType::Boolean, + FieldType::Linked => bitwarden_api_api::models::FieldType::Linked, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bitwarden-vault/src/cipher/identity.rs b/crates/bitwarden-vault/src/cipher/identity.rs index 801c69b18..9b15092db 100644 --- a/crates/bitwarden-vault/src/cipher/identity.rs +++ b/crates/bitwarden-vault/src/cipher/identity.rs @@ -147,6 +147,31 @@ impl TryFrom for Identity { } } +impl From for bitwarden_api_api::models::CipherIdentityModel { + fn from(identity: Identity) -> Self { + Self { + title: identity.title.map(|t| t.to_string()), + first_name: identity.first_name.map(|n| n.to_string()), + middle_name: identity.middle_name.map(|n| n.to_string()), + last_name: identity.last_name.map(|n| n.to_string()), + address1: identity.address1.map(|a| a.to_string()), + address2: identity.address2.map(|a| a.to_string()), + address3: identity.address3.map(|a| a.to_string()), + city: identity.city.map(|c| c.to_string()), + state: identity.state.map(|s| s.to_string()), + postal_code: identity.postal_code.map(|p| p.to_string()), + country: identity.country.map(|c| c.to_string()), + company: identity.company.map(|c| c.to_string()), + email: identity.email.map(|e| e.to_string()), + phone: identity.phone.map(|p| p.to_string()), + ssn: identity.ssn.map(|s| s.to_string()), + username: identity.username.map(|u| u.to_string()), + passport_number: identity.passport_number.map(|p| p.to_string()), + license_number: identity.license_number.map(|l| l.to_string()), + } + } +} + impl CipherKind for Identity { fn decrypt_subtitle( &self, diff --git a/crates/bitwarden-vault/src/cipher/login.rs b/crates/bitwarden-vault/src/cipher/login.rs index 814f68385..8199a8042 100644 --- a/crates/bitwarden-vault/src/cipher/login.rs +++ b/crates/bitwarden-vault/src/cipher/login.rs @@ -84,7 +84,7 @@ impl LoginUriView { } #[allow(missing_docs)] -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] @@ -296,7 +296,7 @@ pub struct Login { } #[allow(missing_docs)] -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] @@ -313,6 +313,31 @@ pub struct LoginView { pub fido2_credentials: Option>, } +impl LoginView { + /// Generate checksums for all URIs in the login view + pub fn generate_checksums(&mut self) { + if let Some(uris) = &mut self.uris { + for uri in uris { + uri.generate_checksum(); + } + } + } + + /// Re-encrypts the fido2 credentials with a new key, replacing the old encrypted values. + pub fn reencrypt_fido2_credentials( + &mut self, + ctx: &mut KeyStoreContext, + old_key: SymmetricKeyId, + new_key: SymmetricKeyId, + ) -> Result<(), CryptoError> { + if let Some(creds) = &mut self.fido2_credentials { + let decrypted_creds: Vec = creds.decrypt(ctx, old_key)?; + *creds = decrypted_creds.encrypt_composite(ctx, new_key)?; + } + Ok(()) + } +} + #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -562,6 +587,70 @@ impl TryFrom for Fido2Cre } } +impl From for bitwarden_api_api::models::CipherLoginUriModel { + fn from(uri: LoginUri) -> Self { + bitwarden_api_api::models::CipherLoginUriModel { + uri: uri.uri.map(|u| u.to_string()), + uri_checksum: uri.uri_checksum.map(|c| c.to_string()), + r#match: uri.r#match.map(|m| m.into()), + } + } +} + +impl From for bitwarden_api_api::models::UriMatchType { + fn from(match_type: UriMatchType) -> Self { + match match_type { + UriMatchType::Domain => bitwarden_api_api::models::UriMatchType::Domain, + UriMatchType::Host => bitwarden_api_api::models::UriMatchType::Host, + UriMatchType::StartsWith => bitwarden_api_api::models::UriMatchType::StartsWith, + UriMatchType::Exact => bitwarden_api_api::models::UriMatchType::Exact, + UriMatchType::RegularExpression => { + bitwarden_api_api::models::UriMatchType::RegularExpression + } + UriMatchType::Never => bitwarden_api_api::models::UriMatchType::Never, + } + } +} + +impl From for bitwarden_api_api::models::CipherFido2CredentialModel { + fn from(cred: Fido2Credential) -> Self { + bitwarden_api_api::models::CipherFido2CredentialModel { + credential_id: Some(cred.credential_id.to_string()), + key_type: Some(cred.key_type.to_string()), + key_algorithm: Some(cred.key_algorithm.to_string()), + key_curve: Some(cred.key_curve.to_string()), + key_value: Some(cred.key_value.to_string()), + rp_id: Some(cred.rp_id.to_string()), + user_handle: cred.user_handle.map(|h| h.to_string()), + user_name: cred.user_name.map(|n| n.to_string()), + counter: Some(cred.counter.to_string()), + rp_name: cred.rp_name.map(|n| n.to_string()), + user_display_name: cred.user_display_name.map(|n| n.to_string()), + discoverable: Some(cred.discoverable.to_string()), + creation_date: cred.creation_date.to_rfc3339(), + } + } +} + +impl From for bitwarden_api_api::models::CipherLoginModel { + fn from(login: Login) -> Self { + bitwarden_api_api::models::CipherLoginModel { + uri: None, + uris: login + .uris + .map(|u| u.into_iter().map(|u| u.into()).collect()), + username: login.username.map(|u| u.to_string()), + password: login.password.map(|p| p.to_string()), + password_revision_date: login.password_revision_date.map(|d| d.to_rfc3339()), + totp: login.totp.map(|t| t.to_string()), + autofill_on_page_load: login.autofill_on_page_load, + fido2_credentials: login + .fido2_credentials + .map(|c| c.into_iter().map(|c| c.into()).collect()), + } + } +} + impl CipherKind for Login { fn decrypt_subtitle( &self, diff --git a/crates/bitwarden-vault/src/cipher/mod.rs b/crates/bitwarden-vault/src/cipher/mod.rs index 39fe85361..6342c744a 100644 --- a/crates/bitwarden-vault/src/cipher/mod.rs +++ b/crates/bitwarden-vault/src/cipher/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod card; pub(crate) mod cipher; pub(crate) mod cipher_client; pub(crate) mod cipher_permissions; +pub(crate) mod cipher_view_type; pub(crate) mod field; pub(crate) mod identity; pub(crate) mod linked_id; @@ -23,6 +24,7 @@ pub use cipher::{ CipherType, CipherView, DecryptCipherListResult, EncryptionContext, }; pub use cipher_client::CiphersClient; +pub use cipher_view_type::CipherViewType; pub use field::{FieldType, FieldView}; pub use identity::IdentityView; pub use login::{ diff --git a/crates/bitwarden-vault/src/cipher/secure_note.rs b/crates/bitwarden-vault/src/cipher/secure_note.rs index 022fc651a..30db99b9c 100644 --- a/crates/bitwarden-vault/src/cipher/secure_note.rs +++ b/crates/bitwarden-vault/src/cipher/secure_note.rs @@ -84,6 +84,22 @@ impl From for SecureNoteType { } } +impl From for bitwarden_api_api::models::SecureNoteType { + fn from(model: SecureNoteType) -> Self { + match model { + SecureNoteType::Generic => bitwarden_api_api::models::SecureNoteType::Generic, + } + } +} + +impl From for CipherSecureNoteModel { + fn from(model: SecureNote) -> Self { + Self { + r#type: Some(model.r#type.into()), + } + } +} + impl CipherKind for SecureNote { fn get_copyable_fields(&self, cipher: Option<&Cipher>) -> Vec { [cipher diff --git a/crates/bitwarden-vault/src/cipher/ssh_key.rs b/crates/bitwarden-vault/src/cipher/ssh_key.rs index c199f1fbd..98c93b7ab 100644 --- a/crates/bitwarden-vault/src/cipher/ssh_key.rs +++ b/crates/bitwarden-vault/src/cipher/ssh_key.rs @@ -95,6 +95,16 @@ impl TryFrom for SshKey { } } +impl From for CipherSshKeyModel { + fn from(ssh_key: SshKey) -> Self { + Self { + private_key: Some(ssh_key.private_key.to_string()), + public_key: Some(ssh_key.public_key.to_string()), + key_fingerprint: Some(ssh_key.fingerprint.to_string()), + } + } +} + #[cfg(test)] mod tests { use bitwarden_core::key_management::create_test_crypto_with_user_key; diff --git a/crates/bitwarden-vault/src/password_history.rs b/crates/bitwarden-vault/src/password_history.rs index 23df58daf..b72905ea3 100644 --- a/crates/bitwarden-vault/src/password_history.rs +++ b/crates/bitwarden-vault/src/password_history.rs @@ -27,8 +27,8 @@ pub struct PasswordHistory { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct PasswordHistoryView { - password: String, - last_used_date: DateTime, + pub password: String, + pub last_used_date: DateTime, } impl IdentifyKey for PasswordHistory { @@ -78,3 +78,28 @@ impl TryFrom for PasswordHistory { }) } } + +impl From for CipherPasswordHistoryModel { + fn from(history: PasswordHistory) -> Self { + Self { + password: history.password.to_string(), + last_used_date: history.last_used_date.to_rfc3339(), + } + } +} + +impl PasswordHistoryView { + pub(crate) fn new_password(old_password: &str) -> Self { + Self { + password: old_password.to_string(), + last_used_date: Utc::now(), + } + } + + pub(crate) fn new_field(field_name: &str, old_value: &str) -> Self { + Self { + password: format!("{field_name}: {old_value}"), + last_used_date: Utc::now(), + } + } +}