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