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, IdentifyKey, KeyStore, SymmetricCryptoKey};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use super::CiphersClient;
17use crate::{
18 AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView,
19 FolderId, ItemNotFoundError, VaultParseError,
20 cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
21 cipher_view_type::CipherViewType,
22};
23
24#[allow(missing_docs)]
25#[bitwarden_error(flat)]
26#[derive(Debug, Error)]
27pub enum EditCipherError {
28 #[error(transparent)]
29 ItemNotFound(#[from] ItemNotFoundError),
30 #[error(transparent)]
31 Crypto(#[from] CryptoError),
32 #[error(transparent)]
33 Api(#[from] ApiError),
34 #[error(transparent)]
35 VaultParse(#[from] VaultParseError),
36 #[error(transparent)]
37 MissingField(#[from] MissingFieldError),
38 #[error(transparent)]
39 NotAuthenticated(#[from] NotAuthenticatedError),
40 #[error(transparent)]
41 Repository(#[from] RepositoryError),
42 #[error(transparent)]
43 Uuid(#[from] uuid::Error),
44 #[error(
47 "Cannot edit a PAM-gated cipher from local state; use `edit_gated` with a full original \
48 obtained under an active lease"
49 )]
50 GatedCipher,
51 #[error("Editing a PAM-gated cipher requires a full original; the supplied view is partial")]
53 PartialOriginal,
54}
55
56#[derive(Clone, Serialize, Deserialize, Debug)]
58#[serde(rename_all = "camelCase")]
59#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
60#[bitwarden_ffi::wasm_record]
61pub struct CipherEditRequest {
62 pub id: CipherId,
64
65 pub organization_id: Option<OrganizationId>,
67 pub folder_id: Option<FolderId>,
69 pub favorite: bool,
71 pub reprompt: CipherRepromptType,
73 pub name: String,
75 pub notes: Option<String>,
77 pub fields: Vec<FieldView>,
79 pub r#type: CipherViewType,
81 pub revision_date: DateTime<Utc>,
83 pub archived_date: Option<DateTime<Utc>>,
85 pub attachments: Vec<AttachmentView>,
87 #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey | undefined"))]
89 pub key: Option<SymmetricCryptoKey>,
90}
91
92impl TryFrom<CipherView> for CipherEditRequest {
93 type Error = MissingFieldError;
94
95 fn try_from(value: CipherView) -> Result<Self, Self::Error> {
96 let type_data = match value.r#type {
97 CipherType::Login => value.login.map(CipherViewType::Login),
98 CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
99 CipherType::Card => value.card.map(CipherViewType::Card),
100 CipherType::Identity => value.identity.map(CipherViewType::Identity),
101 CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
102 CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
103 CipherType::DriversLicense => value.drivers_license.map(CipherViewType::DriversLicense),
104 CipherType::Passport => value.passport.map(CipherViewType::Passport),
105 };
106 Ok(Self {
107 id: value.id.ok_or(MissingFieldError("id"))?,
108 organization_id: value.organization_id,
109 folder_id: value.folder_id,
110 favorite: value.favorite,
111 reprompt: value.reprompt,
112 key: value.key,
113 name: value.name,
114 notes: value.notes,
115 fields: value.fields.unwrap_or_default(),
116 r#type: require!(type_data),
117 attachments: value.attachments.unwrap_or_default(),
118 revision_date: value.revision_date,
119 archived_date: value.archived_date,
120 })
121 }
122}
123
124#[derive(Clone, Serialize, Deserialize, Debug)]
130#[serde(rename_all = "camelCase")]
131#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
132#[bitwarden_ffi::wasm_record]
133pub struct CipherPartialEditRequest {
134 pub id: CipherId,
136 pub folder_id: Option<FolderId>,
138 pub favorite: bool,
140}
141
142pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
148 CipherView {
149 partial: false,
150 id: Some(r.id),
151 organization_id: r.organization_id,
152 folder_id: r.folder_id,
153 collection_ids: vec![],
155 key: r.key,
156 name: r.name,
157 notes: r.notes,
158 r#type: r.r#type.get_cipher_type(),
159 login: r.r#type.as_login_view().cloned(),
160 identity: r.r#type.as_identity_view().cloned(),
161 card: r.r#type.as_card_view().cloned(),
162 secure_note: r.r#type.as_secure_note_view().cloned(),
163 ssh_key: r.r#type.as_ssh_key_view().cloned(),
164 bank_account: r.r#type.as_bank_account_view().cloned(),
165 drivers_license: r.r#type.as_drivers_license_view().cloned(),
166 passport: r.r#type.as_passport_view().cloned(),
167 favorite: r.favorite,
168 reprompt: r.reprompt,
169 organization_use_totp: false,
170 edit: true,
171 permissions: None,
172 view_password: true,
173 local_data: None,
174 attachments: Some(r.attachments),
175 attachment_decryption_failures: None,
176 fields: Some(r.fields),
177 password_history: None,
178 creation_date: Utc::now(),
180 deleted_date: None,
181 revision_date: r.revision_date,
182 archived_date: r.archived_date,
183 }
184}
185
186async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
187 key_store: &KeyStore<KeySlotIds>,
188 api_client: &bitwarden_api_api::apis::ApiClient,
189 repository: &R,
190 encrypted_for: UserId,
191 request: CipherEditRequest,
192 use_strict_decryption: bool,
193 use_blob: bool,
194) -> Result<CipherView, EditCipherError> {
195 let cipher_id = request.id;
196
197 let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
198
199 if original_cipher.partial_data.is_some() {
206 return Err(EditCipherError::GatedCipher);
207 }
208
209 let original_cipher_view: CipherView = if use_strict_decryption {
210 key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
211 } else {
212 key_store.decrypt(&original_cipher)?
213 };
214
215 submit_cipher_edit(
216 key_store,
217 api_client,
218 repository,
219 encrypted_for,
220 request,
221 &original_cipher_view,
222 original_cipher,
223 use_strict_decryption,
224 use_blob,
225 )
226 .await
227}
228
229#[allow(clippy::too_many_arguments)]
240async fn submit_cipher_edit<R: Repository<Cipher> + ?Sized>(
241 key_store: &KeyStore<KeySlotIds>,
242 api_client: &bitwarden_api_api::apis::ApiClient,
243 repository: &R,
244 encrypted_for: UserId,
245 request: CipherEditRequest,
246 original_cipher_view: &CipherView,
247 stored_cipher: Cipher,
248 use_strict_decryption: bool,
249 use_blob: bool,
250) -> Result<CipherView, EditCipherError> {
251 let cipher_id = request.id;
252 let stored_gated = stored_cipher.partial_data.is_some();
253
254 let mut view: CipherView = convert_request_to_cipher_view(request);
255 view.update_password_history(original_cipher_view);
256
257 let encrypted_by_key_id = key_store
258 .context()
259 .get_symmetric_key_id(view.key_identifier())
260 .map(|id| id.to_string());
261
262 let mode = if use_blob {
263 EncryptMode::Blob(view)
264 } else {
265 EncryptMode::Legacy(view)
266 };
267
268 let cipher: Cipher = key_store.encrypt(mode)?;
269 let mut cipher_request: CipherRequestModel = cipher.try_into()?;
270 cipher_request.encrypted_for = Some(encrypted_for.into());
271 cipher_request.encrypted_by_key_id = encrypted_by_key_id;
272
273 let cipher: Cipher = api_client
274 .ciphers_api()
275 .put(cipher_id.into(), Some(cipher_request))
276 .await?
277 .merge_with_cipher(Some(stored_cipher))?;
278 debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
279
280 if !stored_gated || cipher.partial_data.is_some() {
289 repository.set(cipher_id, cipher.clone()).await?;
290 }
291
292 Ok(if use_strict_decryption {
293 key_store.decrypt(&StrictDecrypt(cipher))?
294 } else {
295 key_store.decrypt(&cipher)?
296 })
297}
298
299#[allow(clippy::too_many_arguments)]
310async fn edit_gated_cipher<R: Repository<Cipher> + ?Sized>(
311 key_store: &KeyStore<KeySlotIds>,
312 api_client: &bitwarden_api_api::apis::ApiClient,
313 repository: &R,
314 encrypted_for: UserId,
315 request: CipherEditRequest,
316 original_cipher_view: CipherView,
317 use_strict_decryption: bool,
318 use_blob: bool,
319) -> Result<CipherView, EditCipherError> {
320 let cipher_id = request.id;
321
322 if original_cipher_view.partial {
325 return Err(EditCipherError::PartialOriginal);
326 }
327
328 let stored_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
331
332 submit_cipher_edit(
333 key_store,
334 api_client,
335 repository,
336 encrypted_for,
337 request,
338 &original_cipher_view,
339 stored_cipher,
340 use_strict_decryption,
341 use_blob,
342 )
343 .await
344}
345
346async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
349 key_store: &KeyStore<KeySlotIds>,
350 api_client: &bitwarden_api_api::apis::ApiClient,
351 repository: &R,
352 request: CipherPartialEditRequest,
353 use_strict_decryption: bool,
354) -> Result<CipherView, EditCipherError> {
355 let cipher_id = request.id;
356
357 let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
358
359 let partial_request = CipherPartialRequestModel {
360 folder_id: request.folder_id.map(|id| id.to_string()),
361 favorite: Some(request.favorite),
362 };
363
364 let cipher: Cipher = api_client
365 .ciphers_api()
366 .put_partial(cipher_id.into(), Some(partial_request))
367 .await?
368 .merge_with_cipher(Some(original_cipher))?;
369 debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
370 repository.set(cipher_id, cipher.clone()).await?;
371
372 Ok(if use_strict_decryption {
373 key_store.decrypt(&StrictDecrypt(cipher))?
374 } else {
375 key_store.decrypt(&cipher)?
376 })
377}
378
379#[allow(deprecated)]
380#[bitwarden_ffi::wasm_export]
381impl CiphersClient {
382 pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
384 let key_store = self.client.internal.get_key_store();
385 let config = self.client.internal.get_api_configurations();
386 let repository = self.get_repository()?;
387
388 let user_id = self
389 .client
390 .internal
391 .get_user_id()
392 .ok_or(NotAuthenticatedError)?;
393
394 let use_blob = self.should_use_blob_encryption(request.organization_id);
395
396 edit_cipher(
397 key_store,
398 &config.api_client,
399 repository.as_ref(),
400 user_id,
401 request,
402 self.is_strict_decrypt().await,
403 use_blob,
404 )
405 .await
406 }
407
408 pub async fn edit_gated(
418 &self,
419 request: CipherEditRequest,
420 original_cipher_view: CipherView,
421 ) -> Result<CipherView, EditCipherError> {
422 let key_store = self.client.internal.get_key_store();
423 let config = self.client.internal.get_api_configurations();
424 let repository = self.get_repository()?;
425
426 let user_id = self
427 .client
428 .internal
429 .get_user_id()
430 .ok_or(NotAuthenticatedError)?;
431
432 let use_blob = self.should_use_blob_encryption(request.organization_id);
433
434 edit_gated_cipher(
435 key_store,
436 &config.api_client,
437 repository.as_ref(),
438 user_id,
439 request,
440 original_cipher_view,
441 self.is_strict_decrypt().await,
442 use_blob,
443 )
444 .await
445 }
446
447 pub async fn edit_partial(
452 &self,
453 request: CipherPartialEditRequest,
454 ) -> Result<CipherView, EditCipherError> {
455 let key_store = self.client.internal.get_key_store();
456 let config = self.client.internal.get_api_configurations();
457 let repository = self.get_repository()?;
458
459 partial_edit_cipher(
460 key_store,
461 &config.api_client,
462 repository.as_ref(),
463 request,
464 self.is_strict_decrypt().await,
465 )
466 .await
467 }
468
469 pub async fn update_collection(
471 &self,
472 cipher_id: CipherId,
473 collection_ids: Vec<CollectionId>,
474 ) -> Result<CipherView, EditCipherError> {
475 let req = CipherCollectionsRequestModel {
476 collection_ids: collection_ids
477 .into_iter()
478 .map(|id| id.to_string())
479 .collect(),
480 };
481 let repository = self.get_repository()?;
482
483 let api_config = self.client.internal.get_api_configurations();
484 let api = api_config.api_client.ciphers_api();
485 let orig_cipher = repository.get(cipher_id).await?;
486 let cipher_response = api
487 .put_collections_v_next(cipher_id.into(), Some(req))
488 .await?
489 .cipher
490 .map(|c| *c)
491 .ok_or(MissingFieldError("cipher"))?;
492 let cipher: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
493 repository.set(cipher_id, cipher.clone()).await?;
494
495 Ok(self
496 .decrypt(cipher)
497 .await
498 .map_err(|_| CryptoError::KeyDecrypt)?)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
505 use bitwarden_core::key_management::{
506 SymmetricKeySlotId, create_test_crypto_with_user_and_org_key,
507 };
508 use bitwarden_crypto::{
509 KeyStore, PrimitiveEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm,
510 };
511 use bitwarden_test::MemoryRepository;
512 use chrono::TimeZone;
513
514 use super::*;
515 use crate::{
516 Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
517 PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
518 };
519
520 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
521 const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
522
523 fn generate_test_cipher() -> CipherView {
524 CipherView {
525 partial: false,
526 id: Some(TEST_CIPHER_ID.parse().unwrap()),
527 organization_id: None,
528 folder_id: None,
529 collection_ids: vec![],
530 key: None,
531 name: "Test Login".to_string(),
532 notes: None,
533 r#type: CipherType::Login,
534 login: Some(LoginView {
535 username: Some("[email protected]".to_string()),
536 password: Some("password123".to_string()),
537 password_revision_date: None,
538 uris: None,
539 totp: None,
540 autofill_on_page_load: None,
541 fido2_credentials: None,
542 }),
543 identity: None,
544 card: None,
545 secure_note: None,
546 ssh_key: None,
547 bank_account: None,
548 passport: None,
549 drivers_license: None,
550 favorite: false,
551 reprompt: CipherRepromptType::None,
552 organization_use_totp: true,
553 edit: true,
554 permissions: None,
555 view_password: true,
556 local_data: None,
557 attachments: None,
558 attachment_decryption_failures: None,
559 fields: None,
560 password_history: None,
561 creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
562 deleted_date: None,
563 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
564 archived_date: None,
565 }
566 }
567
568 fn create_test_login_cipher(password: &str) -> CipherView {
569 let mut cipher_view = generate_test_cipher();
570 if let Some(ref mut login) = cipher_view.login {
571 login.password = Some(password.to_string());
572 }
573 cipher_view
574 }
575
576 async fn repository_add_cipher(
577 repository: &MemoryRepository<Cipher>,
578 store: &KeyStore<KeySlotIds>,
579 cipher_id: CipherId,
580 name: &str,
581 ) {
582 let cipher = {
583 let mut ctx = store.context();
584
585 Cipher {
586 partial_data: None,
587 id: Some(cipher_id),
588 organization_id: None,
589 folder_id: None,
590 collection_ids: vec![],
591 key: None,
592 name: Some(name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap()),
593 notes: None,
594 r#type: CipherType::Login,
595 login: Some(Login {
596 username: Some("[email protected]")
597 .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
598 .transpose()
599 .unwrap(),
600 password: Some("password123")
601 .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
602 .transpose()
603 .unwrap(),
604 password_revision_date: None,
605 uris: None,
606 totp: None,
607 autofill_on_page_load: None,
608 fido2_credentials: None,
609 }),
610 identity: None,
611 card: None,
612 secure_note: None,
613 ssh_key: None,
614 bank_account: None,
615 drivers_license: None,
616 passport: None,
617 favorite: false,
618 reprompt: CipherRepromptType::None,
619 organization_use_totp: true,
620 edit: true,
621 permissions: None,
622 view_password: true,
623 local_data: None,
624 attachments: None,
625 fields: None,
626 password_history: None,
627 creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
628 deleted_date: None,
629 revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
630 archived_date: None,
631 data: None,
632 }
633 };
634
635 repository.set(cipher_id, cipher).await.unwrap();
636 }
637
638 #[tokio::test]
639 async fn test_edit_cipher() {
640 let store: KeyStore<KeySlotIds> = KeyStore::default();
641 {
642 let mut ctx = store.context_mut();
643 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
644 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
645 .unwrap();
646 }
647
648 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
649
650 let api_client = ApiClient::new_mocked(move |mock| {
651 mock.ciphers_api
652 .expect_put()
653 .returning(move |_id, body| {
654 let body = body.unwrap();
655 Ok(CipherResponseModel {
656 object: Some("cipher".to_string()),
657 id: Some(cipher_id.into()),
658 name: body.name,
659 r#type: body.r#type,
660 organization_id: body
661 .organization_id
662 .as_ref()
663 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
664 folder_id: body
665 .folder_id
666 .as_ref()
667 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
668 favorite: body.favorite,
669 reprompt: body.reprompt,
670 key: body.key,
671 notes: body.notes,
672 view_password: Some(true),
673 edit: Some(true),
674 organization_use_totp: Some(true),
675 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
676 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
677 deleted_date: None,
678 login: body.login,
679 card: body.card,
680 identity: body.identity,
681 secure_note: body.secure_note,
682 ssh_key: body.ssh_key,
683 bank_account: body.bank_account,
684 drivers_license: body.drivers_license,
685 passport: body.passport,
686 fields: body.fields,
687 password_history: body.password_history,
688 attachments: None,
689 permissions: None,
690 data: None,
691 partial_data: None,
692 archived_date: None,
693 })
694 })
695 .once();
696 });
697
698 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
699
700 let repository = MemoryRepository::<Cipher>::default();
701 repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
702 let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
704 stored.collection_ids = vec![collection_id];
705 repository.set(cipher_id, stored).await.unwrap();
706
707 let cipher_view = generate_test_cipher();
708
709 let request = cipher_view.try_into().unwrap();
710
711 let result = edit_cipher(
712 &store,
713 &api_client,
714 &repository,
715 TEST_USER_ID.parse().unwrap(),
716 request,
717 false,
718 false,
719 )
720 .await
721 .unwrap();
722
723 assert_eq!(result.id, Some(cipher_id));
724 assert_eq!(result.name, "Test Login");
725 assert_eq!(result.collection_ids, vec![collection_id]);
727 }
728
729 const GATED_ORG_UUID: &str = "3cf0d3ba-3ded-4bf3-a51c-b03fd9ac6e07";
733 const GATED_ORG_KEY_B64: &str =
734 "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==";
735 const GATED_LOGIN_ENVELOPE: &str = r#"{"name":"2.qip4DSwdOzU2KwY3jgDjUg==|CsGRQgTwAzmszz+dkk5xIg==|rmW/mlnHq2MulR9uNKclD+1UBFLfOimedkq5tPRSLOc=","uris":[{"uri":"2.2na8mpfA1B1OBTUHkDz+fw==|yTWB1nEf3EHIZgsDINM8JnTYyxf7KVZvXraIGAVOiEg=|i2swsODSjEMRaYNnBHAigdphZBBUg2lkPNo763fX12w=","uriChecksum":null,"match":null}]}"#;
736
737 fn gated_key_store() -> (OrganizationId, KeyStore<KeySlotIds>) {
738 let org: OrganizationId = GATED_ORG_UUID.parse().unwrap();
739 let org_key: SymmetricCryptoKey = GATED_ORG_KEY_B64.to_string().try_into().unwrap();
740 let key_store = create_test_crypto_with_user_and_org_key(
741 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
742 org,
743 org_key,
744 );
745 (org, key_store)
746 }
747
748 fn gated_stored_cipher(cipher_id: CipherId, organization_id: OrganizationId) -> Cipher {
750 Cipher {
751 partial_data: Some(GATED_LOGIN_ENVELOPE.to_string()),
752 id: Some(cipher_id),
753 organization_id: Some(organization_id),
754 folder_id: None,
755 collection_ids: vec![],
756 key: None,
757 name: None,
758 notes: None,
759 r#type: CipherType::Login,
760 login: None,
761 identity: None,
762 card: None,
763 secure_note: None,
764 ssh_key: None,
765 bank_account: None,
766 drivers_license: None,
767 passport: None,
768 favorite: false,
769 reprompt: CipherRepromptType::None,
770 organization_use_totp: false,
771 edit: true,
772 permissions: None,
773 view_password: true,
774 local_data: None,
775 attachments: None,
776 fields: None,
777 password_history: None,
778 creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
779 deleted_date: None,
780 revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
781 archived_date: None,
782 data: None,
783 }
784 }
785
786 fn gated_original_view(organization_id: OrganizationId) -> CipherView {
789 let mut view = create_test_login_cipher("old_password");
790 view.organization_id = Some(organization_id);
791 view.password_history = Some(vec![PasswordHistoryView {
792 password: "older_password".to_string(),
793 last_used_date: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
794 }]);
795 view
796 }
797
798 #[tokio::test]
802 async fn test_edit_refuses_a_gated_stored_cipher() {
803 let (org, store) = gated_key_store();
804 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
805
806 let api_client = ApiClient::new_mocked(|_mock| {});
808
809 let repository = MemoryRepository::<Cipher>::default();
810 repository
811 .set(cipher_id, gated_stored_cipher(cipher_id, org))
812 .await
813 .unwrap();
814
815 let mut cipher_view = generate_test_cipher();
816 cipher_view.organization_id = Some(org);
817
818 let result = edit_cipher(
819 &store,
820 &api_client,
821 &repository,
822 TEST_USER_ID.parse().unwrap(),
823 cipher_view.try_into().unwrap(),
824 false,
825 false,
826 )
827 .await;
828
829 assert!(matches!(result, Err(EditCipherError::GatedCipher)));
830 }
831
832 #[tokio::test]
835 async fn test_edit_gated_refuses_a_partial_original() {
836 let (org, store) = gated_key_store();
837 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
838 let api_client = ApiClient::new_mocked(|_mock| {});
839
840 let repository = MemoryRepository::<Cipher>::default();
841 repository
842 .set(cipher_id, gated_stored_cipher(cipher_id, org))
843 .await
844 .unwrap();
845
846 let mut cipher_view = generate_test_cipher();
847 cipher_view.organization_id = Some(org);
848 let mut original = cipher_view.clone();
849 original.partial = true;
850
851 let result = edit_gated_cipher(
852 &store,
853 &api_client,
854 &repository,
855 TEST_USER_ID.parse().unwrap(),
856 cipher_view.try_into().unwrap(),
857 original,
858 false,
859 false,
860 )
861 .await;
862
863 assert!(matches!(result, Err(EditCipherError::PartialOriginal)));
864 }
865
866 #[tokio::test]
870 async fn test_edit_gated_carries_history_from_the_supplied_original() {
871 let (org, store) = gated_key_store();
872 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
873
874 let api_client = ApiClient::new_mocked(move |mock| {
875 mock.ciphers_api
876 .expect_put()
877 .returning(move |_id, body| {
878 let body = body.unwrap();
879 assert_eq!(body.password_history.as_ref().map(|h| h.len()), Some(2));
882 Ok(gated_response_model(cipher_id, org, body.r#type))
883 })
884 .once();
885 });
886
887 let repository = MemoryRepository::<Cipher>::default();
888 repository
889 .set(cipher_id, gated_stored_cipher(cipher_id, org))
890 .await
891 .unwrap();
892
893 let mut cipher_view = create_test_login_cipher("new_password");
894 cipher_view.organization_id = Some(org);
895
896 let result = edit_gated_cipher(
897 &store,
898 &api_client,
899 &repository,
900 TEST_USER_ID.parse().unwrap(),
901 cipher_view.try_into().unwrap(),
902 gated_original_view(org),
903 false,
904 false,
905 )
906 .await
907 .unwrap();
908
909 assert!(result.partial);
912 let stored = repository.get(cipher_id).await.unwrap().unwrap();
913 assert!(stored.partial_data.is_some());
914 }
915
916 #[tokio::test]
920 async fn test_edit_gated_does_not_persist_a_full_write_return() {
921 let (org, store) = gated_key_store();
922 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
923
924 let api_client = ApiClient::new_mocked(move |mock| {
925 mock.ciphers_api
926 .expect_put()
927 .returning(move |_id, body| {
928 let body = body.unwrap();
929 Ok(CipherResponseModel {
931 partial_data: None,
932 name: body.name,
933 login: body.login,
934 ..gated_response_model(cipher_id, org, body.r#type)
935 })
936 })
937 .once();
938 });
939
940 let repository = MemoryRepository::<Cipher>::default();
941 repository
942 .set(cipher_id, gated_stored_cipher(cipher_id, org))
943 .await
944 .unwrap();
945
946 let mut cipher_view = create_test_login_cipher("new_password");
947 cipher_view.organization_id = Some(org);
948
949 let result = edit_gated_cipher(
950 &store,
951 &api_client,
952 &repository,
953 TEST_USER_ID.parse().unwrap(),
954 cipher_view.try_into().unwrap(),
955 gated_original_view(org),
956 false,
957 false,
958 )
959 .await
960 .unwrap();
961
962 assert!(!result.partial);
964 let stored = repository.get(cipher_id).await.unwrap().unwrap();
965 assert!(stored.partial_data.is_some(), "local state must stay gated");
966 assert!(
967 stored.login.is_none(),
968 "no secret field may reach local state"
969 );
970 }
971
972 fn gated_response_model(
974 cipher_id: CipherId,
975 organization_id: OrganizationId,
976 r#type: Option<bitwarden_api_api::models::CipherType>,
977 ) -> CipherResponseModel {
978 CipherResponseModel {
979 object: Some("cipher".to_string()),
980 id: Some(cipher_id.into()),
981 organization_id: Some(organization_id.into()),
982 r#type,
983 partial_data: Some(GATED_LOGIN_ENVELOPE.to_string()),
984 name: None,
985 notes: None,
986 login: None,
987 card: None,
988 identity: None,
989 secure_note: None,
990 ssh_key: None,
991 bank_account: None,
992 drivers_license: None,
993 passport: None,
994 fields: None,
995 password_history: None,
996 attachments: None,
997 permissions: None,
998 data: None,
999 folder_id: None,
1000 favorite: Some(false),
1001 reprompt: None,
1002 key: None,
1003 view_password: Some(true),
1004 edit: Some(true),
1005 organization_use_totp: Some(false),
1006 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
1007 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
1008 deleted_date: None,
1009 archived_date: None,
1010 }
1011 }
1012
1013 #[tokio::test]
1014 async fn test_edit_partial_cipher() {
1015 let store: KeyStore<KeySlotIds> = KeyStore::default();
1016 {
1017 let mut ctx = store.context_mut();
1018 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
1019 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
1020 .unwrap();
1021 }
1022
1023 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
1024 let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
1025
1026 let api_client = ApiClient::new_mocked(move |mock| {
1027 mock.ciphers_api
1028 .expect_put_partial()
1029 .returning(move |id, body| {
1030 let body = body.unwrap();
1031 let expected_id: uuid::Uuid = cipher_id.into();
1032 assert_eq!(id, expected_id);
1033 assert_eq!(body.favorite, Some(true));
1034 assert_eq!(
1035 body.folder_id.as_deref(),
1036 Some(new_folder_id.to_string().as_str())
1037 );
1038 Ok(CipherResponseModel {
1039 object: Some("cipher".to_string()),
1040 id: Some(cipher_id.into()),
1041 name: Some(
1042 "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
1043 ),
1044 r#type: Some(bitwarden_api_api::models::CipherType::Login),
1045 organization_id: None,
1046 folder_id: Some(new_folder_id.into()),
1047 favorite: Some(true),
1048 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
1049 key: None,
1050 notes: None,
1051 view_password: Some(true),
1052 edit: Some(false),
1053 organization_use_totp: Some(true),
1054 revision_date: Some("2025-01-02T00:00:00Z".to_string()),
1055 creation_date: Some("2024-01-01T00:00:00Z".to_string()),
1056 deleted_date: None,
1057 login: None,
1058 card: None,
1059 identity: None,
1060 secure_note: None,
1061 ssh_key: None,
1062 bank_account: None,
1063 drivers_license: None,
1064 passport: None,
1065 fields: None,
1066 password_history: None,
1067 attachments: None,
1068 permissions: None,
1069 data: None,
1070 partial_data: None,
1071 archived_date: None,
1072 })
1073 })
1074 .once();
1075 });
1076
1077 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
1078
1079 let repository = MemoryRepository::<Cipher>::default();
1080 repository_add_cipher(&repository, &store, cipher_id, "stored_name").await;
1081 let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
1083 stored.collection_ids = vec![collection_id];
1084 repository.set(cipher_id, stored).await.unwrap();
1085
1086 let request = CipherPartialEditRequest {
1087 id: cipher_id,
1088 folder_id: Some(new_folder_id),
1089 favorite: true,
1090 };
1091
1092 let result = partial_edit_cipher(&store, &api_client, &repository, request, false)
1093 .await
1094 .unwrap();
1095
1096 assert_eq!(result.id, Some(cipher_id));
1097 assert_eq!(result.folder_id, Some(new_folder_id));
1098 assert!(result.favorite);
1099 assert_eq!(result.collection_ids, vec![collection_id]);
1101 }
1102
1103 #[tokio::test]
1104 async fn test_edit_partial_cipher_does_not_exist() {
1105 let store: KeyStore<KeySlotIds> = KeyStore::default();
1106
1107 let repository = MemoryRepository::<Cipher>::default();
1108 let api_client = ApiClient::new_mocked(|_| {});
1109
1110 let request = CipherPartialEditRequest {
1111 id: TEST_CIPHER_ID.parse().unwrap(),
1112 folder_id: None,
1113 favorite: false,
1114 };
1115
1116 let result = partial_edit_cipher(&store, &api_client, &repository, request, false).await;
1117
1118 assert!(matches!(
1119 result.unwrap_err(),
1120 EditCipherError::ItemNotFound(_)
1121 ));
1122 }
1123
1124 #[tokio::test]
1125 async fn test_edit_cipher_does_not_exist() {
1126 let store: KeyStore<KeySlotIds> = KeyStore::default();
1127
1128 let repository = MemoryRepository::<Cipher>::default();
1129
1130 let cipher_view = generate_test_cipher();
1131 let api_client = ApiClient::new_mocked(|_| {});
1132
1133 let request = cipher_view.try_into().unwrap();
1134
1135 let result = edit_cipher(
1136 &store,
1137 &api_client,
1138 &repository,
1139 TEST_USER_ID.parse().unwrap(),
1140 request,
1141 false,
1142 false,
1143 )
1144 .await;
1145
1146 assert!(result.is_err());
1147 assert!(matches!(
1148 result.unwrap_err(),
1149 EditCipherError::ItemNotFound(_)
1150 ));
1151 }
1152
1153 #[tokio::test]
1154 async fn test_edit_cipher_http_error() {
1155 let store: KeyStore<KeySlotIds> = KeyStore::default();
1156 {
1157 let mut ctx = store.context_mut();
1158 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
1159 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
1160 .unwrap();
1161 }
1162
1163 let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
1164
1165 let api_client = ApiClient::new_mocked(move |mock| {
1166 mock.ciphers_api
1167 .expect_put()
1168 .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
1169 });
1170
1171 let repository = MemoryRepository::<Cipher>::default();
1172 repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
1173 let cipher_view = generate_test_cipher();
1174
1175 let request = cipher_view.try_into().unwrap();
1176
1177 let result = edit_cipher(
1178 &store,
1179 &api_client,
1180 &repository,
1181 TEST_USER_ID.parse().unwrap(),
1182 request,
1183 false,
1184 false,
1185 )
1186 .await;
1187
1188 assert!(result.is_err());
1189 assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
1190 }
1191
1192 fn edit_view_with_history(new_cipher: CipherView, original: &CipherView) -> CipherView {
1195 let mut view: CipherView =
1196 convert_request_to_cipher_view(CipherEditRequest::try_from(new_cipher).unwrap());
1197 view.update_password_history(original);
1198 view
1199 }
1200
1201 #[test]
1202 fn test_password_history_on_password_change() {
1203 let original_cipher = create_test_login_cipher("old_password");
1204
1205 let start = Utc::now();
1206 let view =
1207 edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
1208 let end = Utc::now();
1209 let history = view.password_history.unwrap_or_default();
1210
1211 assert_eq!(history.len(), 1);
1212 assert!(
1213 history[0].last_used_date >= start && history[0].last_used_date <= end,
1214 "last_used_date was not set properly"
1215 );
1216 assert_eq!(history[0].password, "old_password");
1217 }
1218
1219 #[test]
1220 fn test_password_history_on_unchanged_password() {
1221 let original_cipher = create_test_login_cipher("same_password");
1222 let view =
1223 edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
1224
1225 assert!(view.password_history.unwrap_or_default().is_empty());
1226 }
1227
1228 #[test]
1229 fn test_password_history_is_preserved() {
1230 let mut original_cipher = create_test_login_cipher("same_password");
1231 original_cipher.password_history = Some(
1232 (0..4)
1233 .map(|i| PasswordHistoryView {
1234 password: format!("old_password_{}", i),
1235 last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
1236 })
1237 .collect(),
1238 );
1239
1240 let view =
1241 edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
1242 let history = view.password_history.unwrap_or_default();
1243
1244 assert_eq!(history[0].password, "old_password_0");
1245
1246 assert_eq!(
1247 history[0].last_used_date,
1248 Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
1249 );
1250 assert_eq!(history[1].password, "old_password_1");
1251 assert_eq!(
1252 history[1].last_used_date,
1253 Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
1254 );
1255 assert_eq!(history[2].password, "old_password_2");
1256 assert_eq!(
1257 history[2].last_used_date,
1258 Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
1259 );
1260 assert_eq!(history[3].password, "old_password_3");
1261 assert_eq!(
1262 history[3].last_used_date,
1263 Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
1264 );
1265 }
1266
1267 #[test]
1268 fn test_password_history_with_hidden_fields() {
1269 let mut original_cipher = create_test_login_cipher("password");
1270 original_cipher.fields = Some(vec![FieldView {
1271 name: Some("Secret Key".to_string()),
1272 value: Some("old_secret_value".to_string()),
1273 r#type: FieldType::Hidden,
1274 linked_id: None,
1275 }]);
1276
1277 let mut new_cipher = create_test_login_cipher("password");
1278 new_cipher.fields = Some(vec![FieldView {
1279 name: Some("Secret Key".to_string()),
1280 value: Some("new_secret_value".to_string()),
1281 r#type: FieldType::Hidden,
1282 linked_id: None,
1283 }]);
1284
1285 let view = edit_view_with_history(new_cipher, &original_cipher);
1286 let history = view.password_history.unwrap_or_default();
1287
1288 assert_eq!(history.len(), 1);
1289 assert_eq!(history[0].password, "Secret Key: old_secret_value");
1290 }
1291
1292 #[test]
1293 fn test_password_history_length_limit() {
1294 let mut original_cipher = create_test_login_cipher("password");
1295 original_cipher.password_history = Some(
1296 (0..10)
1297 .map(|i| PasswordHistoryView {
1298 password: format!("old_password_{}", i),
1299 last_used_date: Utc::now(),
1300 })
1301 .collect(),
1302 );
1303
1304 let view =
1305 edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
1306 let history = view.password_history.unwrap_or_default();
1307
1308 assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
1309 assert_eq!(history[0].password, "password");
1311
1312 assert_eq!(history[1].password, "old_password_0");
1313 assert_eq!(history[2].password, "old_password_1");
1314 assert_eq!(history[3].password, "old_password_2");
1315 assert_eq!(history[4].password, "old_password_3");
1316 }
1317
1318 mod blob_encrypt {
1319 use bitwarden_core::key_management::create_test_crypto_with_user_key;
1320 use bitwarden_crypto::SymmetricCryptoKey;
1321
1322 use super::*;
1323 use crate::cipher::blob::try_parse_blob;
1324
1325 #[test]
1329 fn password_history_lives_inside_blob_not_on_wire() {
1330 let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
1331 SymmetricKeyAlgorithm::Aes256CbcHmac,
1332 ));
1333
1334 let original = create_test_login_cipher("old_password");
1335 let mut view = create_test_login_cipher("new_password");
1336 view.update_password_history(&original);
1337 assert_eq!(view.password_history.as_ref().unwrap().len(), 1);
1339
1340 let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
1341
1342 assert!(try_parse_blob(&cipher).is_some());
1343 assert!(
1344 cipher.password_history.is_none(),
1345 "password history must live inside the blob, not on the wire",
1346 );
1347 assert!(cipher.login.is_none());
1348 assert!(cipher.notes.is_none());
1349 }
1350
1351 #[test]
1355 fn password_history_round_trips_through_the_blob() {
1356 let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
1357 SymmetricKeyAlgorithm::Aes256CbcHmac,
1358 ));
1359
1360 let original = create_test_login_cipher("old_password");
1361 let mut view = create_test_login_cipher("new_password");
1362 view.update_password_history(&original);
1363
1364 let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
1365 let restored: CipherView = store.decrypt(&cipher).unwrap();
1366
1367 let history = restored
1368 .password_history
1369 .expect("history should round-trip through the blob");
1370 assert_eq!(history.len(), 1);
1371 assert_eq!(history[0].password, "old_password");
1372 }
1373 }
1374}