1use bitwarden_api_api::models::{
2 CipherCollectionsRequestModel, CipherPartialRequestModel, CipherRequestModel,
3};
4use bitwarden_collections::collection::CollectionId;
5use bitwarden_core::{
6 ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
7 key_management::KeySlotIds, require,
8};
9use bitwarden_crypto::{CryptoError, EncString, IdentifyKey, KeyStore};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20use super::CiphersClient;
21use crate::{
22 AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView,
23 FolderId, ItemNotFoundError, VaultParseError,
24 cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
25 cipher_view_type::CipherViewType,
26};
27
28#[allow(missing_docs)]
29#[bitwarden_error(flat)]
30#[derive(Debug, Error)]
31pub enum EditCipherError {
32 #[error(transparent)]
33 ItemNotFound(#[from] ItemNotFoundError),
34 #[error(transparent)]
35 Crypto(#[from] CryptoError),
36 #[error(transparent)]
37 Api(#[from] ApiError),
38 #[error(transparent)]
39 VaultParse(#[from] VaultParseError),
40 #[error(transparent)]
41 MissingField(#[from] MissingFieldError),
42 #[error(transparent)]
43 NotAuthenticated(#[from] NotAuthenticatedError),
44 #[error(transparent)]
45 Repository(#[from] RepositoryError),
46 #[error(transparent)]
47 Uuid(#[from] uuid::Error),
48}
49
50impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherError {
51 fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
52 Self::Api(val.into())
53 }
54}
55
56#[derive(Clone, Serialize, Deserialize, Debug)]
58#[serde(rename_all = "camelCase")]
59#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
60#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
61pub struct CipherEditRequest {
62 pub id: CipherId,
63
64 pub organization_id: Option<OrganizationId>,
65 pub folder_id: Option<FolderId>,
66 pub favorite: bool,
67 pub reprompt: CipherRepromptType,
68 pub name: String,
69 pub notes: Option<String>,
70 pub fields: Vec<FieldView>,
71 pub r#type: CipherViewType,
72 pub revision_date: DateTime<Utc>,
73 pub archived_date: Option<DateTime<Utc>>,
74 pub attachments: Vec<AttachmentView>,
75 pub key: Option<EncString>,
76}
77
78impl TryFrom<CipherView> for CipherEditRequest {
79 type Error = MissingFieldError;
80
81 fn try_from(value: CipherView) -> Result<Self, Self::Error> {
82 let type_data = match value.r#type {
83 CipherType::Login => value.login.map(CipherViewType::Login),
84 CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
85 CipherType::Card => value.card.map(CipherViewType::Card),
86 CipherType::Identity => value.identity.map(CipherViewType::Identity),
87 CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
88 CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
89 CipherType::DriversLicense => value.drivers_license.map(CipherViewType::DriversLicense),
90 CipherType::Passport => value.passport.map(CipherViewType::Passport),
91 };
92 Ok(Self {
93 id: value.id.ok_or(MissingFieldError("id"))?,
94 organization_id: value.organization_id,
95 folder_id: value.folder_id,
96 favorite: value.favorite,
97 reprompt: value.reprompt,
98 key: value.key,
99 name: value.name,
100 notes: value.notes,
101 fields: value.fields.unwrap_or_default(),
102 r#type: require!(type_data),
103 attachments: value.attachments.unwrap_or_default(),
104 revision_date: value.revision_date,
105 archived_date: value.archived_date,
106 })
107 }
108}
109
110#[derive(Clone, Serialize, Deserialize, Debug)]
116#[serde(rename_all = "camelCase")]
117#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
118#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
119pub struct CipherPartialEditRequest {
120 pub id: CipherId,
121 pub folder_id: Option<FolderId>,
122 pub favorite: bool,
123}
124
125pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
131 CipherView {
132 id: Some(r.id),
133 organization_id: r.organization_id,
134 folder_id: r.folder_id,
135 collection_ids: vec![],
137 key: r.key,
138 name: r.name,
139 notes: r.notes,
140 r#type: r.r#type.get_cipher_type(),
141 login: r.r#type.as_login_view().cloned(),
142 identity: r.r#type.as_identity_view().cloned(),
143 card: r.r#type.as_card_view().cloned(),
144 secure_note: r.r#type.as_secure_note_view().cloned(),
145 ssh_key: r.r#type.as_ssh_key_view().cloned(),
146 bank_account: r.r#type.as_bank_account_view().cloned(),
147 drivers_license: r.r#type.as_drivers_license_view().cloned(),
148 passport: r.r#type.as_passport_view().cloned(),
149 favorite: r.favorite,
150 reprompt: r.reprompt,
151 organization_use_totp: false,
152 edit: true,
153 permissions: None,
154 view_password: true,
155 local_data: None,
156 attachments: Some(r.attachments),
157 attachment_decryption_failures: None,
158 fields: Some(r.fields),
159 password_history: None,
160 creation_date: Utc::now(),
162 deleted_date: None,
163 revision_date: r.revision_date,
164 archived_date: r.archived_date,
165 }
166}
167
168#[allow(clippy::too_many_arguments)]
172async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
173 key_store: &KeyStore<KeySlotIds>,
174 api_client: &bitwarden_api_api::apis::ApiClient,
175 repository: &R,
176 encrypted_for: UserId,
177 request: CipherEditRequest,
178 use_strict_decryption: bool,
179 enable_cipher_key_encryption: bool,
180 use_blob: bool,
181) -> Result<CipherView, EditCipherError> {
182 let cipher_id = request.id;
183
184 let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
185 let original_cipher_view: CipherView = if use_strict_decryption {
186 key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
187 } else {
188 key_store.decrypt(&original_cipher)?
189 };
190
191 let mut view: CipherView = convert_request_to_cipher_view(request);
192 view.update_password_history(&original_cipher_view);
193
194 if view.key.is_none() && enable_cipher_key_encryption {
197 let key = view.key_identifier();
198 view.generate_cipher_key(&mut key_store.context(), key)?;
199 }
200
201 let mode = if use_blob {
202 EncryptMode::Blob(view)
203 } else {
204 EncryptMode::Legacy(view)
205 };
206
207 let cipher: Cipher = key_store.encrypt(mode)?;
208 let mut cipher_request: CipherRequestModel = cipher.try_into()?;
209 cipher_request.encrypted_for = Some(encrypted_for.into());
210
211 let cipher: Cipher = api_client
212 .ciphers_api()
213 .put(cipher_id.into(), Some(cipher_request))
214 .await
215 .map_err(ApiError::from)?
216 .merge_with_cipher(Some(original_cipher))?;
217 debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
218 repository.set(cipher_id, cipher.clone()).await?;
219
220 Ok(if use_strict_decryption {
221 key_store.decrypt(&StrictDecrypt(cipher))?
222 } else {
223 key_store.decrypt(&cipher)?
224 })
225}
226
227async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
230 key_store: &KeyStore<KeySlotIds>,
231 api_client: &bitwarden_api_api::apis::ApiClient,
232 repository: &R,
233 request: CipherPartialEditRequest,
234 use_strict_decryption: bool,
235) -> Result<CipherView, EditCipherError> {
236 let cipher_id = request.id;
237
238 let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
239
240 let partial_request = CipherPartialRequestModel {
241 folder_id: request.folder_id.map(|id| id.to_string()),
242 favorite: Some(request.favorite),
243 };
244
245 let cipher: Cipher = api_client
246 .ciphers_api()
247 .put_partial(cipher_id.into(), Some(partial_request))
248 .await
249 .map_err(ApiError::from)?
250 .merge_with_cipher(Some(original_cipher))?;
251 debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
252 repository.set(cipher_id, cipher.clone()).await?;
253
254 Ok(if use_strict_decryption {
255 key_store.decrypt(&StrictDecrypt(cipher))?
256 } else {
257 key_store.decrypt(&cipher)?
258 })
259}
260
261#[allow(deprecated)]
262#[cfg_attr(feature = "wasm", wasm_bindgen)]
263impl CiphersClient {
264 pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
266 let key_store = self.client.internal.get_key_store();
267 let config = self.client.internal.get_api_configurations();
268 let repository = self.get_repository()?;
269
270 let user_id = self
271 .client
272 .internal
273 .get_user_id()
274 .ok_or(NotAuthenticatedError)?;
275
276 let enable_cipher_key_encryption =
277 self.client.flags().get().await.enable_cipher_key_encryption;
278
279 let use_blob = self.should_use_blob_encryption(request.organization_id);
280
281 edit_cipher(
282 key_store,
283 &config.api_client,
284 repository.as_ref(),
285 user_id,
286 request,
287 self.is_strict_decrypt().await,
288 enable_cipher_key_encryption,
289 use_blob,
290 )
291 .await
292 }
293
294 pub async fn edit_partial(
299 &self,
300 request: CipherPartialEditRequest,
301 ) -> Result<CipherView, EditCipherError> {
302 let key_store = self.client.internal.get_key_store();
303 let config = self.client.internal.get_api_configurations();
304 let repository = self.get_repository()?;
305
306 partial_edit_cipher(
307 key_store,
308 &config.api_client,
309 repository.as_ref(),
310 request,
311 self.is_strict_decrypt().await,
312 )
313 .await
314 }
315
316 pub async fn update_collection(
318 &self,
319 cipher_id: CipherId,
320 collection_ids: Vec<CollectionId>,
321 is_admin: bool,
322 ) -> Result<CipherView, EditCipherError> {
323 let req = CipherCollectionsRequestModel {
324 collection_ids: collection_ids
325 .into_iter()
326 .map(|id| id.to_string())
327 .collect(),
328 };
329 let repository = self.get_repository()?;
330
331 let api_config = self.client.internal.get_api_configurations();
332 let api = api_config.api_client.ciphers_api();
333 let orig_cipher = repository.get(cipher_id).await?;
334 let cipher = if is_admin {
335 api.put_collections_admin(&cipher_id.to_string(), Some(req))
336 .await?
337 .merge_with_cipher(orig_cipher)?
338 } else {
339 let cipher_response = api
340 .put_collections_v_next(cipher_id.into(), Some(req))
341 .await?
342 .cipher
343 .map(|c| *c)
344 .ok_or(MissingFieldError("cipher"))?;
345 let response: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
346 repository.set(cipher_id, response.clone()).await?;
347 response
348 };
349
350 Ok(self
351 .decrypt(cipher)
352 .await
353 .map_err(|_| CryptoError::KeyDecrypt)?)
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
360 use bitwarden_core::key_management::SymmetricKeySlotId;
361 use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
362 use bitwarden_test::MemoryRepository;
363 use chrono::TimeZone;
364
365 use super::*;
366 use crate::{
367 Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
368 PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
369 };
370
371 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
372 const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
373
374 fn generate_test_cipher() -> CipherView {
375 CipherView {
376 id: Some(TEST_CIPHER_ID.parse().unwrap()),
377 organization_id: None,
378 folder_id: None,
379 collection_ids: vec![],
380 key: None,
381 name: "Test Login".to_string(),
382 notes: None,
383 r#type: CipherType::Login,
384 login: Some(LoginView {
385 username: Some("[email protected]".to_string()),
386 password: Some("password123".to_string()),
387 password_revision_date: None,
388 uris: None,
389 totp: None,
390 autofill_on_page_load: None,
391 fido2_credentials: None,
392 }),
393 identity: None,
394 card: None,
395 secure_note: None,
396 ssh_key: None,
397 bank_account: None,
398 passport: None,
399 drivers_license: None,
400 favorite: false,
401 reprompt: CipherRepromptType::None,
402 organization_use_totp: true,
403 edit: true,
404 permissions: None,
405 view_password: true,
406 local_data: None,
407 attachments: None,
408 attachment_decryption_failures: None,
409 fields: None,
410 password_history: None,
411 creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
412 deleted_date: None,
413 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
414 archived_date: None,
415 }
416 }
417
418 fn create_test_login_cipher(password: &str) -> CipherView {
419 let mut cipher_view = generate_test_cipher();
420 if let Some(ref mut login) = cipher_view.login {
421 login.password = Some(password.to_string());
422 }
423 cipher_view
424 }
425
426 async fn repository_add_cipher(
427 repository: &MemoryRepository<Cipher>,
428 store: &KeyStore<KeySlotIds>,
429 cipher_id: CipherId,
430 name: &str,
431 ) {
432 let cipher = {
433 let mut ctx = store.context();
434
435 Cipher {
436 id: Some(cipher_id),
437 organization_id: None,
438 folder_id: None,
439 collection_ids: vec![],
440 key: None,
441 name: Some(name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap()),
442 notes: None,
443 r#type: CipherType::Login,
444 login: Some(Login {
445 username: Some("[email protected]")
446 .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
447 .transpose()
448 .unwrap(),
449 password: Some("password123")
450 .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
451 .transpose()
452 .unwrap(),
453 password_revision_date: None,
454 uris: None,
455 totp: None,
456 autofill_on_page_load: None,
457 fido2_credentials: None,
458 }),
459 identity: None,
460 card: None,
461 secure_note: None,
462 ssh_key: None,
463 bank_account: None,
464 drivers_license: None,
465 passport: None,
466 favorite: false,
467 reprompt: CipherRepromptType::None,
468 organization_use_totp: true,
469 edit: true,
470 permissions: None,
471 view_password: true,
472 local_data: None,
473 attachments: None,
474 fields: None,
475 password_history: None,
476 creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
477 deleted_date: None,
478 revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
479 archived_date: None,
480 data: None,
481 }
482 };
483
484 repository.set(cipher_id, cipher).await.unwrap();
485 }
486
487 #[tokio::test]
488 async fn test_edit_cipher() {
489 let store: KeyStore<KeySlotIds> = KeyStore::default();
490 {
491 let mut ctx = store.context_mut();
492 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
493 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
494 .unwrap();
495 }
496
497 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
498
499 let api_client = ApiClient::new_mocked(move |mock| {
500 mock.ciphers_api
501 .expect_put()
502 .returning(move |_id, body| {
503 let body = body.unwrap();
504 Ok(CipherResponseModel {
505 object: Some("cipher".to_string()),
506 id: Some(cipher_id.into()),
507 name: Some(body.name),
508 r#type: body.r#type,
509 organization_id: body
510 .organization_id
511 .as_ref()
512 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
513 folder_id: body
514 .folder_id
515 .as_ref()
516 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
517 favorite: body.favorite,
518 reprompt: body.reprompt,
519 key: body.key,
520 notes: body.notes,
521 view_password: Some(true),
522 edit: Some(true),
523 organization_use_totp: Some(true),
524 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
525 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
526 deleted_date: None,
527 login: body.login,
528 card: body.card,
529 identity: body.identity,
530 secure_note: body.secure_note,
531 ssh_key: body.ssh_key,
532 bank_account: body.bank_account,
533 drivers_license: body.drivers_license,
534 passport: body.passport,
535 fields: body.fields,
536 password_history: body.password_history,
537 attachments: None,
538 permissions: None,
539 data: None,
540 archived_date: None,
541 })
542 })
543 .once();
544 });
545
546 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
547
548 let repository = MemoryRepository::<Cipher>::default();
549 repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
550 let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
552 stored.collection_ids = vec![collection_id];
553 repository.set(cipher_id, stored).await.unwrap();
554
555 let cipher_view = generate_test_cipher();
556
557 let request = cipher_view.try_into().unwrap();
558
559 let result = edit_cipher(
560 &store,
561 &api_client,
562 &repository,
563 TEST_USER_ID.parse().unwrap(),
564 request,
565 false,
566 false,
567 false,
568 )
569 .await
570 .unwrap();
571
572 assert_eq!(result.id, Some(cipher_id));
573 assert_eq!(result.name, "Test Login");
574 assert_eq!(result.collection_ids, vec![collection_id]);
576 }
577
578 #[tokio::test]
579 async fn test_edit_partial_cipher() {
580 let store: KeyStore<KeySlotIds> = KeyStore::default();
581 {
582 let mut ctx = store.context_mut();
583 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
584 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
585 .unwrap();
586 }
587
588 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
589 let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
590
591 let api_client = ApiClient::new_mocked(move |mock| {
592 mock.ciphers_api
593 .expect_put_partial()
594 .returning(move |id, body| {
595 let body = body.unwrap();
596 let expected_id: uuid::Uuid = cipher_id.into();
597 assert_eq!(id, expected_id);
598 assert_eq!(body.favorite, Some(true));
599 assert_eq!(
600 body.folder_id.as_deref(),
601 Some(new_folder_id.to_string().as_str())
602 );
603 Ok(CipherResponseModel {
604 object: Some("cipher".to_string()),
605 id: Some(cipher_id.into()),
606 name: Some(
607 "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
608 ),
609 r#type: Some(bitwarden_api_api::models::CipherType::Login),
610 organization_id: None,
611 folder_id: Some(new_folder_id.into()),
612 favorite: Some(true),
613 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
614 key: None,
615 notes: None,
616 view_password: Some(true),
617 edit: Some(false),
618 organization_use_totp: Some(true),
619 revision_date: Some("2025-01-02T00:00:00Z".to_string()),
620 creation_date: Some("2024-01-01T00:00:00Z".to_string()),
621 deleted_date: None,
622 login: None,
623 card: None,
624 identity: None,
625 secure_note: None,
626 ssh_key: None,
627 bank_account: None,
628 drivers_license: None,
629 passport: None,
630 fields: None,
631 password_history: None,
632 attachments: None,
633 permissions: None,
634 data: None,
635 archived_date: None,
636 })
637 })
638 .once();
639 });
640
641 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
642
643 let repository = MemoryRepository::<Cipher>::default();
644 repository_add_cipher(&repository, &store, cipher_id, "stored_name").await;
645 let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
647 stored.collection_ids = vec![collection_id];
648 repository.set(cipher_id, stored).await.unwrap();
649
650 let request = CipherPartialEditRequest {
651 id: cipher_id,
652 folder_id: Some(new_folder_id),
653 favorite: true,
654 };
655
656 let result = partial_edit_cipher(&store, &api_client, &repository, request, false)
657 .await
658 .unwrap();
659
660 assert_eq!(result.id, Some(cipher_id));
661 assert_eq!(result.folder_id, Some(new_folder_id));
662 assert!(result.favorite);
663 assert_eq!(result.collection_ids, vec![collection_id]);
665 }
666
667 #[tokio::test]
668 async fn test_edit_partial_cipher_does_not_exist() {
669 let store: KeyStore<KeySlotIds> = KeyStore::default();
670
671 let repository = MemoryRepository::<Cipher>::default();
672 let api_client = ApiClient::new_mocked(|_| {});
673
674 let request = CipherPartialEditRequest {
675 id: TEST_CIPHER_ID.parse().unwrap(),
676 folder_id: None,
677 favorite: false,
678 };
679
680 let result = partial_edit_cipher(&store, &api_client, &repository, request, false).await;
681
682 assert!(matches!(
683 result.unwrap_err(),
684 EditCipherError::ItemNotFound(_)
685 ));
686 }
687
688 #[tokio::test]
689 async fn test_edit_cipher_does_not_exist() {
690 let store: KeyStore<KeySlotIds> = KeyStore::default();
691
692 let repository = MemoryRepository::<Cipher>::default();
693
694 let cipher_view = generate_test_cipher();
695 let api_client = ApiClient::new_mocked(|_| {});
696
697 let request = cipher_view.try_into().unwrap();
698
699 let result = edit_cipher(
700 &store,
701 &api_client,
702 &repository,
703 TEST_USER_ID.parse().unwrap(),
704 request,
705 false,
706 false,
707 false,
708 )
709 .await;
710
711 assert!(result.is_err());
712 assert!(matches!(
713 result.unwrap_err(),
714 EditCipherError::ItemNotFound(_)
715 ));
716 }
717
718 #[tokio::test]
719 async fn test_edit_cipher_http_error() {
720 let store: KeyStore<KeySlotIds> = KeyStore::default();
721 {
722 let mut ctx = store.context_mut();
723 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
724 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
725 .unwrap();
726 }
727
728 let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
729
730 let api_client = ApiClient::new_mocked(move |mock| {
731 mock.ciphers_api
732 .expect_put()
733 .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
734 });
735
736 let repository = MemoryRepository::<Cipher>::default();
737 repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
738 let cipher_view = generate_test_cipher();
739
740 let request = cipher_view.try_into().unwrap();
741
742 let result = edit_cipher(
743 &store,
744 &api_client,
745 &repository,
746 TEST_USER_ID.parse().unwrap(),
747 request,
748 false,
749 false,
750 false,
751 )
752 .await;
753
754 assert!(result.is_err());
755 assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
756 }
757
758 fn edit_view_with_history(new_cipher: CipherView, original: &CipherView) -> CipherView {
761 let mut view: CipherView =
762 convert_request_to_cipher_view(CipherEditRequest::try_from(new_cipher).unwrap());
763 view.update_password_history(original);
764 view
765 }
766
767 #[test]
768 fn test_password_history_on_password_change() {
769 let original_cipher = create_test_login_cipher("old_password");
770
771 let start = Utc::now();
772 let view =
773 edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
774 let end = Utc::now();
775 let history = view.password_history.unwrap_or_default();
776
777 assert_eq!(history.len(), 1);
778 assert!(
779 history[0].last_used_date >= start && history[0].last_used_date <= end,
780 "last_used_date was not set properly"
781 );
782 assert_eq!(history[0].password, "old_password");
783 }
784
785 #[test]
786 fn test_password_history_on_unchanged_password() {
787 let original_cipher = create_test_login_cipher("same_password");
788 let view =
789 edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
790
791 assert!(view.password_history.unwrap_or_default().is_empty());
792 }
793
794 #[test]
795 fn test_password_history_is_preserved() {
796 let mut original_cipher = create_test_login_cipher("same_password");
797 original_cipher.password_history = Some(
798 (0..4)
799 .map(|i| PasswordHistoryView {
800 password: format!("old_password_{}", i),
801 last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
802 })
803 .collect(),
804 );
805
806 let view =
807 edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
808 let history = view.password_history.unwrap_or_default();
809
810 assert_eq!(history[0].password, "old_password_0");
811
812 assert_eq!(
813 history[0].last_used_date,
814 Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
815 );
816 assert_eq!(history[1].password, "old_password_1");
817 assert_eq!(
818 history[1].last_used_date,
819 Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
820 );
821 assert_eq!(history[2].password, "old_password_2");
822 assert_eq!(
823 history[2].last_used_date,
824 Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
825 );
826 assert_eq!(history[3].password, "old_password_3");
827 assert_eq!(
828 history[3].last_used_date,
829 Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
830 );
831 }
832
833 #[test]
834 fn test_password_history_with_hidden_fields() {
835 let mut original_cipher = create_test_login_cipher("password");
836 original_cipher.fields = Some(vec![FieldView {
837 name: Some("Secret Key".to_string()),
838 value: Some("old_secret_value".to_string()),
839 r#type: FieldType::Hidden,
840 linked_id: None,
841 }]);
842
843 let mut new_cipher = create_test_login_cipher("password");
844 new_cipher.fields = Some(vec![FieldView {
845 name: Some("Secret Key".to_string()),
846 value: Some("new_secret_value".to_string()),
847 r#type: FieldType::Hidden,
848 linked_id: None,
849 }]);
850
851 let view = edit_view_with_history(new_cipher, &original_cipher);
852 let history = view.password_history.unwrap_or_default();
853
854 assert_eq!(history.len(), 1);
855 assert_eq!(history[0].password, "Secret Key: old_secret_value");
856 }
857
858 #[test]
859 fn test_password_history_length_limit() {
860 let mut original_cipher = create_test_login_cipher("password");
861 original_cipher.password_history = Some(
862 (0..10)
863 .map(|i| PasswordHistoryView {
864 password: format!("old_password_{}", i),
865 last_used_date: Utc::now(),
866 })
867 .collect(),
868 );
869
870 let view =
871 edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
872 let history = view.password_history.unwrap_or_default();
873
874 assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
875 assert_eq!(history[0].password, "password");
877
878 assert_eq!(history[1].password, "old_password_0");
879 assert_eq!(history[2].password, "old_password_1");
880 assert_eq!(history[3].password, "old_password_2");
881 assert_eq!(history[4].password, "old_password_3");
882 }
883
884 mod blob_encrypt {
885 use bitwarden_core::key_management::create_test_crypto_with_user_key;
886 use bitwarden_crypto::SymmetricCryptoKey;
887
888 use super::*;
889 use crate::cipher::blob::try_parse_blob;
890
891 #[test]
895 fn password_history_lives_inside_blob_not_on_wire() {
896 let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
897 SymmetricKeyAlgorithm::Aes256CbcHmac,
898 ));
899
900 let original = create_test_login_cipher("old_password");
901 let mut view = create_test_login_cipher("new_password");
902 view.update_password_history(&original);
903 assert_eq!(view.password_history.as_ref().unwrap().len(), 1);
905
906 let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
907
908 assert!(try_parse_blob(&cipher).is_some());
909 assert!(
910 cipher.password_history.is_none(),
911 "password history must live inside the blob, not on the wire",
912 );
913 assert!(cipher.login.is_none());
914 assert!(cipher.notes.is_none());
915 }
916
917 #[test]
921 fn password_history_round_trips_through_the_blob() {
922 let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
923 SymmetricKeyAlgorithm::Aes256CbcHmac,
924 ));
925
926 let original = create_test_login_cipher("old_password");
927 let mut view = create_test_login_cipher("new_password");
928 view.update_password_history(&original);
929
930 let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
931 let restored: CipherView = store.decrypt(&cipher).unwrap();
932
933 let history = restored
934 .password_history
935 .expect("history should round-trip through the blob");
936 assert_eq!(history.len(), 1);
937 assert_eq!(history[0].password, "old_password");
938 }
939 }
940}