Skip to main content

bitwarden_vault/cipher/
cipher.rs

1use bitwarden_api_api::models::{
2    CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel,
3    CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel,
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{
7    ApiError, MissingFieldError, OrganizationId, UserId,
8    key_management::{KeyIds, MINIMUM_ENFORCE_ICON_URI_HASH_VERSION, SymmetricKeyId},
9    require,
10};
11use bitwarden_crypto::{
12    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
13    PrimitiveEncryptable,
14};
15use bitwarden_error::bitwarden_error;
16use bitwarden_state::repository::RepositoryError;
17use bitwarden_uuid::uuid_newtype;
18use chrono::{DateTime, SecondsFormat, Utc};
19use serde::{Deserialize, Serialize};
20use serde_repr::{Deserialize_repr, Serialize_repr};
21use thiserror::Error;
22use tracing::instrument;
23#[cfg(feature = "wasm")]
24use tsify::Tsify;
25#[cfg(feature = "wasm")]
26use wasm_bindgen::prelude::wasm_bindgen;
27
28use super::{
29    attachment, card,
30    card::CardListView,
31    cipher_permissions::CipherPermissions,
32    field, identity,
33    local_data::{LocalData, LocalDataView},
34    login::LoginListView,
35    secure_note, ssh_key,
36};
37use crate::{
38    AttachmentView, DecryptError, EncryptError, Fido2CredentialFullView, Fido2CredentialView,
39    FieldView, FolderId, Login, LoginView, VaultParseError,
40    password_history::{self, MAX_PASSWORD_HISTORY_ENTRIES},
41};
42
43uuid_newtype!(pub CipherId);
44
45#[allow(missing_docs)]
46#[bitwarden_error(flat)]
47#[derive(Debug, Error)]
48pub enum CipherError {
49    #[error(transparent)]
50    MissingField(#[from] MissingFieldError),
51    #[error(transparent)]
52    Crypto(#[from] CryptoError),
53    #[error(transparent)]
54    Decrypt(#[from] DecryptError),
55    #[error(transparent)]
56    Encrypt(#[from] EncryptError),
57    #[error(
58        "This cipher contains attachments without keys. Those attachments will need to be reuploaded to complete the operation"
59    )]
60    AttachmentsWithoutKeys,
61    #[error("This cipher cannot be moved to the specified organization")]
62    OrganizationAlreadySet,
63    #[error(transparent)]
64    Repository(#[from] RepositoryError),
65    #[error(transparent)]
66    Chrono(#[from] chrono::ParseError),
67    #[error(transparent)]
68    SerdeJson(#[from] serde_json::Error),
69    #[error(transparent)]
70    Api(#[from] ApiError),
71}
72
73impl<T> From<bitwarden_api_api::apis::Error<T>> for CipherError {
74    fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
75        Self::Api(value.into())
76    }
77}
78
79/// Helper trait for operations on cipher types.
80pub(super) trait CipherKind {
81    /// Returns the item's subtitle.
82    fn decrypt_subtitle(
83        &self,
84        ctx: &mut KeyStoreContext<KeyIds>,
85        key: SymmetricKeyId,
86    ) -> Result<String, CryptoError>;
87
88    /// Returns a list of populated fields for the cipher.
89    fn get_copyable_fields(&self, cipher: Option<&Cipher>) -> Vec<CopyableCipherFields>;
90}
91
92#[allow(missing_docs)]
93#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
94#[repr(u8)]
95#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
96#[cfg_attr(feature = "wasm", wasm_bindgen)]
97pub enum CipherType {
98    Login = 1,
99    SecureNote = 2,
100    Card = 3,
101    Identity = 4,
102    SshKey = 5,
103}
104
105#[allow(missing_docs)]
106#[derive(Clone, Copy, Default, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
107#[repr(u8)]
108#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
109#[cfg_attr(feature = "wasm", wasm_bindgen)]
110pub enum CipherRepromptType {
111    #[default]
112    None = 0,
113    Password = 1,
114}
115
116#[allow(missing_docs)]
117#[derive(Serialize, Deserialize, Debug, Clone)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
120#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
121pub struct EncryptionContext {
122    /// The Id of the user that encrypted the cipher. It should always represent a UserId, even for
123    /// Organization-owned ciphers
124    pub encrypted_for: UserId,
125    pub cipher: Cipher,
126}
127
128impl TryFrom<EncryptionContext> for CipherWithIdRequestModel {
129    type Error = CipherError;
130    fn try_from(
131        EncryptionContext {
132            cipher,
133            encrypted_for,
134        }: EncryptionContext,
135    ) -> Result<Self, Self::Error> {
136        Ok(Self {
137            id: require!(cipher.id).into(),
138            encrypted_for: Some(encrypted_for.into()),
139            r#type: Some(cipher.r#type.into()),
140            organization_id: cipher.organization_id.map(|o| o.to_string()),
141            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
142            favorite: cipher.favorite.into(),
143            reprompt: Some(cipher.reprompt.into()),
144            key: cipher.key.map(|k| k.to_string()),
145            name: cipher.name.to_string(),
146            notes: cipher.notes.map(|n| n.to_string()),
147            fields: Some(
148                cipher
149                    .fields
150                    .into_iter()
151                    .flatten()
152                    .map(Into::into)
153                    .collect(),
154            ),
155            password_history: Some(
156                cipher
157                    .password_history
158                    .into_iter()
159                    .flatten()
160                    .map(Into::into)
161                    .collect(),
162            ),
163            attachments: None,
164            attachments2: Some(
165                cipher
166                    .attachments
167                    .into_iter()
168                    .flatten()
169                    .filter_map(|a| {
170                        a.id.map(|id| {
171                            (
172                                id,
173                                bitwarden_api_api::models::CipherAttachmentModel {
174                                    file_name: a.file_name.map(|n| n.to_string()),
175                                    key: a.key.map(|k| k.to_string()),
176                                },
177                            )
178                        })
179                    })
180                    .collect(),
181            ),
182            login: cipher.login.map(|l| Box::new(l.into())),
183            card: cipher.card.map(|c| Box::new(c.into())),
184            identity: cipher.identity.map(|i| Box::new(i.into())),
185            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
186            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
187            data: None, // TODO: Consume this instead of the individual fields above.
188            last_known_revision_date: Some(
189                cipher
190                    .revision_date
191                    .to_rfc3339_opts(SecondsFormat::Millis, true),
192            ),
193            archived_date: cipher
194                .archived_date
195                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
196        })
197    }
198}
199
200impl From<EncryptionContext> for CipherRequestModel {
201    fn from(
202        EncryptionContext {
203            cipher,
204            encrypted_for,
205        }: EncryptionContext,
206    ) -> Self {
207        Self {
208            encrypted_for: Some(encrypted_for.into()),
209            r#type: Some(cipher.r#type.into()),
210            organization_id: cipher.organization_id.map(|o| o.to_string()),
211            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
212            favorite: cipher.favorite.into(),
213            reprompt: Some(cipher.reprompt.into()),
214            key: cipher.key.map(|k| k.to_string()),
215            name: cipher.name.to_string(),
216            notes: cipher.notes.map(|n| n.to_string()),
217            fields: Some(
218                cipher
219                    .fields
220                    .into_iter()
221                    .flatten()
222                    .map(Into::into)
223                    .collect(),
224            ),
225            password_history: Some(
226                cipher
227                    .password_history
228                    .into_iter()
229                    .flatten()
230                    .map(Into::into)
231                    .collect(),
232            ),
233            attachments: None,
234            attachments2: Some(
235                cipher
236                    .attachments
237                    .into_iter()
238                    .flatten()
239                    .filter_map(|a| {
240                        a.id.map(|id| {
241                            (
242                                id,
243                                bitwarden_api_api::models::CipherAttachmentModel {
244                                    file_name: a.file_name.map(|n| n.to_string()),
245                                    key: a.key.map(|k| k.to_string()),
246                                },
247                            )
248                        })
249                    })
250                    .collect(),
251            ),
252            login: cipher.login.map(|l| Box::new(l.into())),
253            card: cipher.card.map(|c| Box::new(c.into())),
254            identity: cipher.identity.map(|i| Box::new(i.into())),
255            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
256            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
257            data: None, // TODO: Consume this instead of the individual fields above.
258            last_known_revision_date: Some(
259                cipher
260                    .revision_date
261                    .to_rfc3339_opts(SecondsFormat::Millis, true),
262            ),
263            archived_date: cipher
264                .archived_date
265                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
266        }
267    }
268}
269
270#[allow(missing_docs)]
271#[derive(Serialize, Deserialize, Debug, Clone)]
272#[serde(rename_all = "camelCase", deny_unknown_fields)]
273#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
274#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
275pub struct Cipher {
276    pub id: Option<CipherId>,
277    pub organization_id: Option<OrganizationId>,
278    pub folder_id: Option<FolderId>,
279    pub collection_ids: Vec<CollectionId>,
280    /// More recent ciphers uses individual encryption keys to encrypt the other fields of the
281    /// Cipher.
282    pub key: Option<EncString>,
283
284    pub name: EncString,
285    pub notes: Option<EncString>,
286
287    pub r#type: CipherType,
288    pub login: Option<Login>,
289    pub identity: Option<identity::Identity>,
290    pub card: Option<card::Card>,
291    pub secure_note: Option<secure_note::SecureNote>,
292    pub ssh_key: Option<ssh_key::SshKey>,
293
294    pub favorite: bool,
295    pub reprompt: CipherRepromptType,
296    pub organization_use_totp: bool,
297    pub edit: bool,
298    pub permissions: Option<CipherPermissions>,
299    pub view_password: bool,
300    pub local_data: Option<LocalData>,
301
302    pub attachments: Option<Vec<attachment::Attachment>>,
303    pub fields: Option<Vec<field::Field>>,
304    pub password_history: Option<Vec<password_history::PasswordHistory>>,
305
306    pub creation_date: DateTime<Utc>,
307    pub deleted_date: Option<DateTime<Utc>>,
308    pub revision_date: DateTime<Utc>,
309    pub archived_date: Option<DateTime<Utc>>,
310    pub data: Option<String>,
311}
312
313/// Represents the result of re-wrapping a cipher key, which can be needed when changing the
314/// ownership of a cipher or rotating keys.
315pub enum CipherKeyRewrapError {
316    NoCipherKey,
317    DecryptionFailure,
318    EncryptionFailure,
319}
320
321impl Cipher {
322    /// Re-wraps the encrypted cipher-key. This should be done when moving the cipher to a new
323    /// ownership (user to org), or when rotating the owning key. This mutates the cipher's key
324    /// field if successful, otherwise returns an error. Data stays encrypted the same way and
325    /// does not need to be re-uploaded to the server.
326    pub fn rewrap_cipher_key(
327        &mut self,
328        old_key: SymmetricKeyId,
329        new_key: SymmetricKeyId,
330        ctx: &mut KeyStoreContext<KeyIds>,
331    ) -> Result<(), CipherKeyRewrapError> {
332        let new_cipher_key = self
333            .key
334            .as_ref()
335            .ok_or(CipherKeyRewrapError::NoCipherKey)
336            .and_then(|wrapped_cipher_key| {
337                ctx.unwrap_symmetric_key(old_key, wrapped_cipher_key)
338                    .map_err(|_| CipherKeyRewrapError::DecryptionFailure)
339            })
340            .and_then(|cipher_key| {
341                ctx.wrap_symmetric_key(new_key, cipher_key)
342                    .map_err(|_| CipherKeyRewrapError::EncryptionFailure)
343            })?;
344        self.key = Some(new_cipher_key);
345        Ok(())
346    }
347}
348
349bitwarden_state::register_repository_item!(CipherId => Cipher, "Cipher");
350
351#[allow(missing_docs)]
352#[derive(Serialize, Deserialize, Debug, Clone)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
355#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
356pub struct CipherView {
357    pub id: Option<CipherId>,
358    pub organization_id: Option<OrganizationId>,
359    pub folder_id: Option<FolderId>,
360    pub collection_ids: Vec<CollectionId>,
361
362    /// Temporary, required to support re-encrypting existing items.
363    pub key: Option<EncString>,
364
365    pub name: String,
366    pub notes: Option<String>,
367
368    pub r#type: CipherType,
369    pub login: Option<LoginView>,
370    pub identity: Option<identity::IdentityView>,
371    pub card: Option<card::CardView>,
372    pub secure_note: Option<secure_note::SecureNoteView>,
373    pub ssh_key: Option<ssh_key::SshKeyView>,
374
375    pub favorite: bool,
376    pub reprompt: CipherRepromptType,
377    pub organization_use_totp: bool,
378    pub edit: bool,
379    pub permissions: Option<CipherPermissions>,
380    pub view_password: bool,
381    pub local_data: Option<LocalDataView>,
382
383    pub attachments: Option<Vec<attachment::AttachmentView>>,
384    /// Attachments that failed to decrypt. Only present when there are decryption failures.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub attachment_decryption_failures: Option<Vec<attachment::AttachmentView>>,
387    pub fields: Option<Vec<field::FieldView>>,
388    pub password_history: Option<Vec<password_history::PasswordHistoryView>>,
389    pub creation_date: DateTime<Utc>,
390    pub deleted_date: Option<DateTime<Utc>>,
391    pub revision_date: DateTime<Utc>,
392    pub archived_date: Option<DateTime<Utc>>,
393}
394
395#[allow(missing_docs)]
396#[derive(Serialize, Deserialize, Debug, PartialEq)]
397#[serde(rename_all = "camelCase", deny_unknown_fields)]
398#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
399#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
400pub enum CipherListViewType {
401    Login(LoginListView),
402    SecureNote,
403    Card(CardListView),
404    Identity,
405    SshKey,
406}
407
408/// Available fields on a cipher and can be copied from a the list view in the UI.
409#[derive(Serialize, Deserialize, Debug, PartialEq)]
410#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
411#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
412pub enum CopyableCipherFields {
413    LoginUsername,
414    LoginPassword,
415    LoginTotp,
416    CardNumber,
417    CardSecurityCode,
418    IdentityUsername,
419    IdentityEmail,
420    IdentityPhone,
421    IdentityAddress,
422    SshKey,
423    SecureNotes,
424}
425
426#[allow(missing_docs)]
427#[derive(Serialize, Deserialize, Debug, PartialEq)]
428#[serde(rename_all = "camelCase", deny_unknown_fields)]
429#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
430#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
431pub struct CipherListView {
432    pub id: Option<CipherId>,
433    pub organization_id: Option<OrganizationId>,
434    pub folder_id: Option<FolderId>,
435    pub collection_ids: Vec<CollectionId>,
436
437    /// Temporary, required to support calculating TOTP from CipherListView.
438    pub key: Option<EncString>,
439
440    pub name: String,
441    pub subtitle: String,
442
443    pub r#type: CipherListViewType,
444
445    pub favorite: bool,
446    pub reprompt: CipherRepromptType,
447    pub organization_use_totp: bool,
448    pub edit: bool,
449    pub permissions: Option<CipherPermissions>,
450
451    pub view_password: bool,
452
453    /// The number of attachments
454    pub attachments: u32,
455    /// Indicates if the cipher has old attachments that need to be re-uploaded
456    pub has_old_attachments: bool,
457
458    pub creation_date: DateTime<Utc>,
459    pub deleted_date: Option<DateTime<Utc>>,
460    pub revision_date: DateTime<Utc>,
461    pub archived_date: Option<DateTime<Utc>>,
462
463    /// Hints for the presentation layer for which fields can be copied.
464    pub copyable_fields: Vec<CopyableCipherFields>,
465
466    pub local_data: Option<LocalDataView>,
467
468    /// Decrypted cipher notes for search indexing.
469    #[cfg(feature = "wasm")]
470    pub notes: Option<String>,
471    /// Decrypted cipher fields for search indexing.
472    /// Only includes name and value (for text fields only).
473    #[cfg(feature = "wasm")]
474    pub fields: Option<Vec<field::FieldListView>>,
475    /// Decrypted attachment filenames for search indexing.
476    #[cfg(feature = "wasm")]
477    pub attachment_names: Option<Vec<String>>,
478}
479
480/// Represents the result of decrypting a list of ciphers.
481///
482/// This struct contains two vectors: `successes` and `failures`.
483/// `successes` contains the decrypted `CipherListView` objects,
484/// while `failures` contains the original `Cipher` objects that failed to decrypt.
485#[derive(Serialize, Deserialize, Debug)]
486#[serde(rename_all = "camelCase", deny_unknown_fields)]
487#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
488#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
489pub struct DecryptCipherListResult {
490    /// The decrypted `CipherListView` objects.
491    pub successes: Vec<CipherListView>,
492    /// The original `Cipher` objects that failed to decrypt.
493    pub failures: Vec<Cipher>,
494}
495
496/// Represents the result of decrypting a list of ciphers.
497///
498/// This struct contains two vectors: `successes` and `failures`.
499/// `successes` contains the decrypted `CipherView` objects,
500/// while `failures` contains the original `Cipher` objects that failed to decrypt.
501#[derive(Serialize, Deserialize, Debug)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
504pub struct DecryptCipherResult {
505    /// The decrypted `CipherView` objects.
506    pub successes: Vec<CipherView>,
507    /// The original `Cipher` objects that failed to decrypt.
508    pub failures: Vec<Cipher>,
509}
510
511/// Represents the result of fetching and decrypting all ciphers for an organization.
512///
513/// Contains the encrypted ciphers from the API alongside their decrypted list views.
514#[derive(Serialize, Deserialize, Debug)]
515#[serde(rename_all = "camelCase", deny_unknown_fields)]
516#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
517#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
518pub struct ListOrganizationCiphersResult {
519    /// All encrypted ciphers returned from the API.
520    pub ciphers: Vec<Cipher>,
521    /// Successfully decrypted `CipherListView` objects.
522    pub list_views: Vec<CipherListView>,
523}
524
525impl CipherListView {
526    pub(crate) fn get_totp_key(
527        self,
528        ctx: &mut KeyStoreContext<KeyIds>,
529    ) -> Result<Option<String>, CryptoError> {
530        let key = self.key_identifier();
531        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
532
533        let totp = match self.r#type {
534            CipherListViewType::Login(LoginListView { totp, .. }) => {
535                totp.map(|t| t.decrypt(ctx, ciphers_key)).transpose()?
536            }
537            _ => None,
538        };
539
540        Ok(totp)
541    }
542}
543
544impl CompositeEncryptable<KeyIds, SymmetricKeyId, Cipher> for CipherView {
545    fn encrypt_composite(
546        &self,
547        ctx: &mut KeyStoreContext<KeyIds>,
548        key: SymmetricKeyId,
549    ) -> Result<Cipher, CryptoError> {
550        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
551
552        let mut cipher_view = self.clone();
553        cipher_view.generate_checksums();
554
555        Ok(Cipher {
556            id: cipher_view.id,
557            organization_id: cipher_view.organization_id,
558            folder_id: cipher_view.folder_id,
559            collection_ids: cipher_view.collection_ids,
560            key: cipher_view.key,
561            name: cipher_view.name.encrypt(ctx, ciphers_key)?,
562            notes: cipher_view.notes.encrypt(ctx, ciphers_key)?,
563            r#type: cipher_view.r#type,
564            login: cipher_view.login.encrypt_composite(ctx, ciphers_key)?,
565            identity: cipher_view.identity.encrypt_composite(ctx, ciphers_key)?,
566            card: cipher_view.card.encrypt_composite(ctx, ciphers_key)?,
567            secure_note: cipher_view
568                .secure_note
569                .encrypt_composite(ctx, ciphers_key)?,
570            ssh_key: cipher_view.ssh_key.encrypt_composite(ctx, ciphers_key)?,
571            favorite: cipher_view.favorite,
572            reprompt: cipher_view.reprompt,
573            organization_use_totp: cipher_view.organization_use_totp,
574            edit: cipher_view.edit,
575            view_password: cipher_view.view_password,
576            local_data: cipher_view.local_data.encrypt_composite(ctx, ciphers_key)?,
577            attachments: cipher_view
578                .attachments
579                .encrypt_composite(ctx, ciphers_key)?,
580            fields: cipher_view.fields.encrypt_composite(ctx, ciphers_key)?,
581            password_history: cipher_view
582                .password_history
583                .encrypt_composite(ctx, ciphers_key)?,
584            creation_date: cipher_view.creation_date,
585            deleted_date: cipher_view.deleted_date,
586            revision_date: cipher_view.revision_date,
587            permissions: cipher_view.permissions,
588            archived_date: cipher_view.archived_date,
589            data: None, // TODO: Do we need to repopulate this on this on the cipher?
590        })
591    }
592}
593
594impl Decryptable<KeyIds, SymmetricKeyId, CipherView> for Cipher {
595    #[instrument(err, skip_all, fields(cipher_id = ?self.id, org_id = ?self.organization_id, kind = ?self.r#type))]
596    fn decrypt(
597        &self,
598        ctx: &mut KeyStoreContext<KeyIds>,
599        key: SymmetricKeyId,
600    ) -> Result<CipherView, CryptoError> {
601        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
602
603        // Separate successful and failed attachment decryptions
604        let (attachments, attachment_decryption_failures) =
605            attachment::decrypt_attachments_with_failures(
606                self.attachments.as_deref().unwrap_or_default(),
607                ctx,
608                ciphers_key,
609            );
610
611        let mut cipher = CipherView {
612            id: self.id,
613            organization_id: self.organization_id,
614            folder_id: self.folder_id,
615            collection_ids: self.collection_ids.clone(),
616            key: self.key.clone(),
617            name: self.name.decrypt(ctx, ciphers_key).ok().unwrap_or_default(),
618            notes: self.notes.decrypt(ctx, ciphers_key).ok().flatten(),
619            r#type: self.r#type,
620            login: self.login.decrypt(ctx, ciphers_key).ok().flatten(),
621            identity: self.identity.decrypt(ctx, ciphers_key).ok().flatten(),
622            card: self.card.decrypt(ctx, ciphers_key).ok().flatten(),
623            secure_note: self.secure_note.decrypt(ctx, ciphers_key).ok().flatten(),
624            ssh_key: self.ssh_key.decrypt(ctx, ciphers_key).ok().flatten(),
625            favorite: self.favorite,
626            reprompt: self.reprompt,
627            organization_use_totp: self.organization_use_totp,
628            edit: self.edit,
629            permissions: self.permissions,
630            view_password: self.view_password,
631            local_data: self.local_data.decrypt(ctx, ciphers_key).ok().flatten(),
632            attachments: Some(attachments),
633            attachment_decryption_failures: Some(attachment_decryption_failures),
634            fields: self.fields.decrypt(ctx, ciphers_key).ok().flatten(),
635            password_history: self
636                .password_history
637                .decrypt(ctx, ciphers_key)
638                .ok()
639                .flatten(),
640            creation_date: self.creation_date,
641            deleted_date: self.deleted_date,
642            revision_date: self.revision_date,
643            archived_date: self.archived_date,
644        };
645
646        // For compatibility we only remove URLs with invalid checksums if the cipher has a key
647        // or the user is on Crypto V2
648        if cipher.key.is_some()
649            || ctx.get_security_state_version() >= MINIMUM_ENFORCE_ICON_URI_HASH_VERSION
650        {
651            cipher.remove_invalid_checksums();
652        }
653
654        Ok(cipher)
655    }
656}
657
658impl Cipher {
659    /// Decrypt the individual encryption key for this cipher into the provided [KeyStoreContext]
660    /// and return it's identifier. Note that some ciphers do not have individual encryption
661    /// keys, in which case this will return the provided key identifier instead
662    ///
663    /// # Arguments
664    ///
665    /// * `ctx` - The key store context where the cipher key will be decrypted, if it exists
666    /// * `key` - The key to use to decrypt the cipher key, this should be the user or organization
667    ///   key
668    /// * `ciphers_key` - The encrypted cipher key
669    #[instrument(err, skip_all)]
670    pub(super) fn decrypt_cipher_key(
671        ctx: &mut KeyStoreContext<KeyIds>,
672        key: SymmetricKeyId,
673        ciphers_key: &Option<EncString>,
674    ) -> Result<SymmetricKeyId, CryptoError> {
675        match ciphers_key {
676            Some(ciphers_key) => ctx.unwrap_symmetric_key(key, ciphers_key),
677            None => Ok(key),
678        }
679    }
680
681    /// Temporary helper to return a [CipherKind] instance based on the cipher type.
682    fn get_kind(&self) -> Option<&dyn CipherKind> {
683        match self.r#type {
684            CipherType::Login => self.login.as_ref().map(|v| v as _),
685            CipherType::Card => self.card.as_ref().map(|v| v as _),
686            CipherType::Identity => self.identity.as_ref().map(|v| v as _),
687            CipherType::SshKey => self.ssh_key.as_ref().map(|v| v as _),
688            CipherType::SecureNote => self.secure_note.as_ref().map(|v| v as _),
689        }
690    }
691
692    /// Returns the decrypted subtitle for the cipher, if applicable.
693    fn decrypt_subtitle(
694        &self,
695        ctx: &mut KeyStoreContext<KeyIds>,
696        key: SymmetricKeyId,
697    ) -> Result<String, CryptoError> {
698        self.get_kind()
699            .map(|sub| sub.decrypt_subtitle(ctx, key))
700            .unwrap_or_else(|| Ok(String::new()))
701    }
702
703    /// Returns a list of copyable field names for this cipher,
704    /// based on the cipher type and populated properties.
705    fn get_copyable_fields(&self) -> Vec<CopyableCipherFields> {
706        self.get_kind()
707            .map(|kind| kind.get_copyable_fields(Some(self)))
708            .unwrap_or_default()
709    }
710
711    /// This replaces the values provided by the API in the `login`, `secure_note`, `card`,
712    /// `identity`, and `ssh_key` fields, relying instead on client-side parsing of the
713    /// `data` field.
714    #[allow(unused)] // Will be used by future changes to support cipher versioning.
715    pub(crate) fn populate_cipher_types(&mut self) -> Result<(), VaultParseError> {
716        let data = self
717            .data
718            .as_ref()
719            .ok_or(VaultParseError::MissingField(MissingFieldError("data")))?;
720
721        match &self.r#type {
722            crate::CipherType::Login => self.login = serde_json::from_str(data)?,
723            crate::CipherType::SecureNote => self.secure_note = serde_json::from_str(data)?,
724            crate::CipherType::Card => self.card = serde_json::from_str(data)?,
725            crate::CipherType::Identity => self.identity = serde_json::from_str(data)?,
726            crate::CipherType::SshKey => self.ssh_key = serde_json::from_str(data)?,
727        }
728        Ok(())
729    }
730
731    /// Marks the cipher as soft deleted by setting `deletion_date` to now.
732    pub(crate) fn soft_delete(&mut self) {
733        self.deleted_date = Some(Utc::now());
734    }
735}
736impl CipherView {
737    #[allow(missing_docs)]
738    pub fn generate_cipher_key(
739        &mut self,
740        ctx: &mut KeyStoreContext<KeyIds>,
741        wrapping_key: SymmetricKeyId,
742    ) -> Result<(), CryptoError> {
743        let old_unwrapping_key = self.key_identifier();
744        let old_ciphers_key = Cipher::decrypt_cipher_key(ctx, old_unwrapping_key, &self.key)?;
745
746        let new_key = ctx.generate_symmetric_key();
747
748        self.reencrypt_attachment_keys(ctx, old_ciphers_key, new_key)?;
749        self.reencrypt_fido2_credentials(ctx, old_ciphers_key, new_key)?;
750
751        self.key = Some(ctx.wrap_symmetric_key(wrapping_key, new_key)?);
752        Ok(())
753    }
754
755    #[allow(missing_docs)]
756    pub fn generate_checksums(&mut self) {
757        if let Some(l) = self.login.as_mut() {
758            l.generate_checksums();
759        }
760    }
761
762    #[allow(missing_docs)]
763    pub fn remove_invalid_checksums(&mut self) {
764        if let Some(uris) = self.login.as_mut().and_then(|l| l.uris.as_mut()) {
765            uris.retain(|u| u.is_checksum_valid());
766        }
767    }
768
769    fn reencrypt_attachment_keys(
770        &mut self,
771        ctx: &mut KeyStoreContext<KeyIds>,
772        old_key: SymmetricKeyId,
773        new_key: SymmetricKeyId,
774    ) -> Result<(), CryptoError> {
775        if let Some(attachments) = &mut self.attachments {
776            AttachmentView::reencrypt_keys(attachments, ctx, old_key, new_key)?;
777        }
778        Ok(())
779    }
780
781    #[allow(missing_docs)]
782    pub fn decrypt_fido2_credentials(
783        &self,
784        ctx: &mut KeyStoreContext<KeyIds>,
785    ) -> Result<Vec<Fido2CredentialView>, CryptoError> {
786        let key = self.key_identifier();
787        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
788
789        Ok(self
790            .login
791            .as_ref()
792            .and_then(|l| l.fido2_credentials.as_ref())
793            .map(|f| f.decrypt(ctx, ciphers_key))
794            .transpose()?
795            .unwrap_or_default())
796    }
797
798    fn reencrypt_fido2_credentials(
799        &mut self,
800        ctx: &mut KeyStoreContext<KeyIds>,
801        old_key: SymmetricKeyId,
802        new_key: SymmetricKeyId,
803    ) -> Result<(), CryptoError> {
804        if let Some(login) = self.login.as_mut() {
805            login.reencrypt_fido2_credentials(ctx, old_key, new_key)?;
806        }
807        Ok(())
808    }
809
810    /// Moves the cipher to an organization by re-encrypting the cipher keys with the organization
811    /// key and assigning the organization ID to the cipher.
812    ///
813    /// # Arguments
814    /// * `ctx` - The key store context where the cipher keys will be re-encrypted
815    /// * `organization_id` - The ID of the organization to move the cipher to
816    pub fn move_to_organization(
817        &mut self,
818        ctx: &mut KeyStoreContext<KeyIds>,
819        organization_id: OrganizationId,
820    ) -> Result<(), CipherError> {
821        let new_key = SymmetricKeyId::Organization(organization_id);
822
823        self.reencrypt_cipher_keys(ctx, new_key)?;
824        self.organization_id = Some(organization_id);
825
826        Ok(())
827    }
828
829    /// Re-encrypt the cipher key(s) using a new wrapping key.
830    ///
831    /// If the cipher has a cipher key, it will be re-encrypted with the new wrapping key.
832    /// Otherwise, the cipher will re-encrypt all attachment keys and FIDO2 credential keys
833    pub fn reencrypt_cipher_keys(
834        &mut self,
835        ctx: &mut KeyStoreContext<KeyIds>,
836        new_wrapping_key: SymmetricKeyId,
837    ) -> Result<(), CipherError> {
838        let old_key = self.key_identifier();
839
840        // If any attachment is missing a key we can't reencrypt the attachment keys
841        if self.attachments.iter().flatten().any(|a| a.key.is_none()) {
842            return Err(CipherError::AttachmentsWithoutKeys);
843        }
844
845        // If the cipher has a key, reencrypt it with the new wrapping key
846        if self.key.is_some() {
847            // Decrypt the current cipher key using the existing wrapping key
848            let cipher_key = Cipher::decrypt_cipher_key(ctx, old_key, &self.key)?;
849
850            // Wrap the cipher key with the new wrapping key
851            self.key = Some(ctx.wrap_symmetric_key(new_wrapping_key, cipher_key)?);
852        } else {
853            // The cipher does not have a key, we must reencrypt all attachment keys and FIDO2
854            // credentials individually
855            self.reencrypt_attachment_keys(ctx, old_key, new_wrapping_key)?;
856            self.reencrypt_fido2_credentials(ctx, old_key, new_wrapping_key)?;
857        }
858
859        Ok(())
860    }
861
862    #[allow(missing_docs)]
863    pub fn set_new_fido2_credentials(
864        &mut self,
865        ctx: &mut KeyStoreContext<KeyIds>,
866        creds: Vec<Fido2CredentialFullView>,
867    ) -> Result<(), CipherError> {
868        let key = self.key_identifier();
869
870        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
871
872        require!(self.login.as_mut()).fido2_credentials =
873            Some(creds.encrypt_composite(ctx, ciphers_key)?);
874
875        Ok(())
876    }
877
878    #[allow(missing_docs)]
879    pub fn get_fido2_credentials(
880        &self,
881        ctx: &mut KeyStoreContext<KeyIds>,
882    ) -> Result<Vec<Fido2CredentialFullView>, CipherError> {
883        let key = self.key_identifier();
884
885        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
886
887        let login = require!(self.login.as_ref());
888        let creds = require!(login.fido2_credentials.as_ref());
889        let res = creds.decrypt(ctx, ciphers_key)?;
890        Ok(res)
891    }
892
893    #[allow(missing_docs)]
894    pub fn decrypt_fido2_private_key(
895        &self,
896        ctx: &mut KeyStoreContext<KeyIds>,
897    ) -> Result<String, CipherError> {
898        let fido2_credential = self.get_fido2_credentials(ctx)?;
899
900        Ok(fido2_credential[0].key_value.clone())
901    }
902
903    pub(crate) fn update_password_history(&mut self, original_cipher: &CipherView) {
904        let changes = self
905            .login
906            .as_mut()
907            .map_or(vec![], |login| {
908                login.detect_password_change(&original_cipher.login)
909            })
910            .into_iter()
911            .chain(self.fields.as_deref().map_or(vec![], |fields| {
912                FieldView::detect_hidden_field_changes(
913                    fields,
914                    original_cipher.fields.as_deref().unwrap_or(&[]),
915                )
916            }))
917            .rev()
918            .chain(original_cipher.password_history.iter().flatten().cloned())
919            .take(MAX_PASSWORD_HISTORY_ENTRIES)
920            .collect();
921        self.password_history = Some(changes)
922    }
923}
924
925impl Decryptable<KeyIds, SymmetricKeyId, CipherListView> for Cipher {
926    fn decrypt(
927        &self,
928        ctx: &mut KeyStoreContext<KeyIds>,
929        key: SymmetricKeyId,
930    ) -> Result<CipherListView, CryptoError> {
931        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
932
933        Ok(CipherListView {
934            id: self.id,
935            organization_id: self.organization_id,
936            folder_id: self.folder_id,
937            collection_ids: self.collection_ids.clone(),
938            key: self.key.clone(),
939            name: self.name.decrypt(ctx, ciphers_key).ok().unwrap_or_default(),
940            subtitle: self
941                .decrypt_subtitle(ctx, ciphers_key)
942                .ok()
943                .unwrap_or_default(),
944            r#type: match self.r#type {
945                CipherType::Login => {
946                    let login = self
947                        .login
948                        .as_ref()
949                        .ok_or(CryptoError::MissingField("login"))?;
950                    CipherListViewType::Login(login.decrypt(ctx, ciphers_key)?)
951                }
952                CipherType::SecureNote => CipherListViewType::SecureNote,
953                CipherType::Card => {
954                    let card = self
955                        .card
956                        .as_ref()
957                        .ok_or(CryptoError::MissingField("card"))?;
958                    CipherListViewType::Card(card.decrypt(ctx, ciphers_key)?)
959                }
960                CipherType::Identity => CipherListViewType::Identity,
961                CipherType::SshKey => CipherListViewType::SshKey,
962            },
963            favorite: self.favorite,
964            reprompt: self.reprompt,
965            organization_use_totp: self.organization_use_totp,
966            edit: self.edit,
967            permissions: self.permissions,
968            view_password: self.view_password,
969            attachments: self
970                .attachments
971                .as_ref()
972                .map(|a| a.len() as u32)
973                .unwrap_or(0),
974            has_old_attachments: self
975                .attachments
976                .as_ref()
977                .map(|a| a.iter().any(|att| att.key.is_none()))
978                .unwrap_or(false),
979            creation_date: self.creation_date,
980            deleted_date: self.deleted_date,
981            revision_date: self.revision_date,
982            copyable_fields: self.get_copyable_fields(),
983            local_data: self.local_data.decrypt(ctx, ciphers_key)?,
984            archived_date: self.archived_date,
985            #[cfg(feature = "wasm")]
986            notes: self.notes.decrypt(ctx, ciphers_key).ok().flatten(),
987            #[cfg(feature = "wasm")]
988            fields: self.fields.as_ref().map(|fields| {
989                fields
990                    .iter()
991                    .filter_map(|f| {
992                        f.decrypt(ctx, ciphers_key)
993                            .ok()
994                            .map(field::FieldListView::from)
995                    })
996                    .collect()
997            }),
998            #[cfg(feature = "wasm")]
999            attachment_names: self.attachments.as_ref().map(|attachments| {
1000                attachments
1001                    .iter()
1002                    .filter_map(|a| a.file_name.decrypt(ctx, ciphers_key).ok().flatten())
1003                    .collect()
1004            }),
1005        })
1006    }
1007}
1008
1009impl IdentifyKey<SymmetricKeyId> for Cipher {
1010    fn key_identifier(&self) -> SymmetricKeyId {
1011        match self.organization_id {
1012            Some(organization_id) => SymmetricKeyId::Organization(organization_id),
1013            None => SymmetricKeyId::User,
1014        }
1015    }
1016}
1017
1018impl IdentifyKey<SymmetricKeyId> for CipherView {
1019    fn key_identifier(&self) -> SymmetricKeyId {
1020        match self.organization_id {
1021            Some(organization_id) => SymmetricKeyId::Organization(organization_id),
1022            None => SymmetricKeyId::User,
1023        }
1024    }
1025}
1026
1027impl IdentifyKey<SymmetricKeyId> for CipherListView {
1028    fn key_identifier(&self) -> SymmetricKeyId {
1029        match self.organization_id {
1030            Some(organization_id) => SymmetricKeyId::Organization(organization_id),
1031            None => SymmetricKeyId::User,
1032        }
1033    }
1034}
1035
1036impl TryFrom<CipherDetailsResponseModel> for Cipher {
1037    type Error = VaultParseError;
1038
1039    fn try_from(cipher: CipherDetailsResponseModel) -> Result<Self, Self::Error> {
1040        Ok(Self {
1041            id: cipher.id.map(CipherId::new),
1042            organization_id: cipher.organization_id.map(OrganizationId::new),
1043            folder_id: cipher.folder_id.map(FolderId::new),
1044            collection_ids: cipher
1045                .collection_ids
1046                .unwrap_or_default()
1047                .into_iter()
1048                .map(CollectionId::new)
1049                .collect(),
1050            name: require!(EncString::try_from_optional(cipher.name)?),
1051            notes: EncString::try_from_optional(cipher.notes)?,
1052            r#type: require!(cipher.r#type).try_into()?,
1053            login: cipher.login.map(|l| (*l).try_into()).transpose()?,
1054            identity: cipher.identity.map(|i| (*i).try_into()).transpose()?,
1055            card: cipher.card.map(|c| (*c).try_into()).transpose()?,
1056            secure_note: cipher.secure_note.map(|s| (*s).try_into()).transpose()?,
1057            ssh_key: cipher.ssh_key.map(|s| (*s).try_into()).transpose()?,
1058            favorite: cipher.favorite.unwrap_or(false),
1059            reprompt: cipher
1060                .reprompt
1061                .map(|r| r.try_into())
1062                .transpose()?
1063                .unwrap_or(CipherRepromptType::None),
1064            organization_use_totp: cipher.organization_use_totp.unwrap_or(true),
1065            edit: cipher.edit.unwrap_or(true),
1066            permissions: cipher.permissions.map(|p| (*p).try_into()).transpose()?,
1067            view_password: cipher.view_password.unwrap_or(true),
1068            local_data: None, // Not sent from server
1069            attachments: cipher
1070                .attachments
1071                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
1072                .transpose()?,
1073            fields: cipher
1074                .fields
1075                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
1076                .transpose()?,
1077            password_history: cipher
1078                .password_history
1079                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
1080                .transpose()?,
1081            creation_date: require!(cipher.creation_date).parse()?,
1082            deleted_date: cipher.deleted_date.map(|d| d.parse()).transpose()?,
1083            revision_date: require!(cipher.revision_date).parse()?,
1084            key: EncString::try_from_optional(cipher.key)?,
1085            archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?,
1086            data: cipher.data,
1087        })
1088    }
1089}
1090
1091impl PartialCipher for CipherDetailsResponseModel {
1092    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
1093        Ok(Cipher {
1094            local_data: cipher.and_then(|c| c.local_data),
1095            ..self.try_into()?
1096        })
1097    }
1098}
1099
1100impl TryFrom<bitwarden_api_api::models::CipherType> for CipherType {
1101    type Error = MissingFieldError;
1102
1103    fn try_from(t: bitwarden_api_api::models::CipherType) -> Result<Self, Self::Error> {
1104        Ok(match t {
1105            bitwarden_api_api::models::CipherType::Login => CipherType::Login,
1106            bitwarden_api_api::models::CipherType::SecureNote => CipherType::SecureNote,
1107            bitwarden_api_api::models::CipherType::Card => CipherType::Card,
1108            bitwarden_api_api::models::CipherType::Identity => CipherType::Identity,
1109            bitwarden_api_api::models::CipherType::SSHKey => CipherType::SshKey,
1110            bitwarden_api_api::models::CipherType::__Unknown(_) => {
1111                return Err(MissingFieldError("type"));
1112            }
1113        })
1114    }
1115}
1116
1117impl TryFrom<bitwarden_api_api::models::CipherRepromptType> for CipherRepromptType {
1118    type Error = MissingFieldError;
1119
1120    fn try_from(t: bitwarden_api_api::models::CipherRepromptType) -> Result<Self, Self::Error> {
1121        Ok(match t {
1122            bitwarden_api_api::models::CipherRepromptType::None => CipherRepromptType::None,
1123            bitwarden_api_api::models::CipherRepromptType::Password => CipherRepromptType::Password,
1124            bitwarden_api_api::models::CipherRepromptType::__Unknown(_) => {
1125                return Err(MissingFieldError("reprompt"));
1126            }
1127        })
1128    }
1129}
1130
1131/// A trait for merging partial cipher data into a full cipher.
1132/// Used to convert from API response models to full Cipher structs,
1133/// without losing local data that may not be present in the API response.
1134pub(crate) trait PartialCipher {
1135    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError>;
1136}
1137
1138impl From<CipherType> for bitwarden_api_api::models::CipherType {
1139    fn from(t: CipherType) -> Self {
1140        match t {
1141            CipherType::Login => bitwarden_api_api::models::CipherType::Login,
1142            CipherType::SecureNote => bitwarden_api_api::models::CipherType::SecureNote,
1143            CipherType::Card => bitwarden_api_api::models::CipherType::Card,
1144            CipherType::Identity => bitwarden_api_api::models::CipherType::Identity,
1145            CipherType::SshKey => bitwarden_api_api::models::CipherType::SSHKey,
1146        }
1147    }
1148}
1149
1150impl From<CipherRepromptType> for bitwarden_api_api::models::CipherRepromptType {
1151    fn from(t: CipherRepromptType) -> Self {
1152        match t {
1153            CipherRepromptType::None => bitwarden_api_api::models::CipherRepromptType::None,
1154            CipherRepromptType::Password => bitwarden_api_api::models::CipherRepromptType::Password,
1155        }
1156    }
1157}
1158
1159impl TryFrom<CipherResponseModel> for Cipher {
1160    type Error = VaultParseError;
1161
1162    fn try_from(cipher: CipherResponseModel) -> Result<Self, Self::Error> {
1163        Ok(Self {
1164            id: cipher.id.map(CipherId::new),
1165            organization_id: cipher.organization_id.map(OrganizationId::new),
1166            folder_id: cipher.folder_id.map(FolderId::new),
1167            collection_ids: vec![], // CipherResponseModel doesn't include collection_ids
1168            name: require!(cipher.name).parse()?,
1169            notes: EncString::try_from_optional(cipher.notes)?,
1170            r#type: require!(cipher.r#type).try_into()?,
1171            login: cipher.login.map(|l| (*l).try_into()).transpose()?,
1172            identity: cipher.identity.map(|i| (*i).try_into()).transpose()?,
1173            card: cipher.card.map(|c| (*c).try_into()).transpose()?,
1174            secure_note: cipher.secure_note.map(|s| (*s).try_into()).transpose()?,
1175            ssh_key: cipher.ssh_key.map(|s| (*s).try_into()).transpose()?,
1176            favorite: cipher.favorite.unwrap_or(false),
1177            reprompt: cipher
1178                .reprompt
1179                .map(|r| r.try_into())
1180                .transpose()?
1181                .unwrap_or(CipherRepromptType::None),
1182            organization_use_totp: cipher.organization_use_totp.unwrap_or(false),
1183            edit: cipher.edit.unwrap_or(false),
1184            permissions: cipher.permissions.map(|p| (*p).try_into()).transpose()?,
1185            view_password: cipher.view_password.unwrap_or(true),
1186            local_data: None, // Not sent from server
1187            attachments: cipher
1188                .attachments
1189                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
1190                .transpose()?,
1191            fields: cipher
1192                .fields
1193                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
1194                .transpose()?,
1195            password_history: cipher
1196                .password_history
1197                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
1198                .transpose()?,
1199            creation_date: require!(cipher.creation_date).parse()?,
1200            deleted_date: cipher.deleted_date.map(|d| d.parse()).transpose()?,
1201            revision_date: require!(cipher.revision_date).parse()?,
1202            key: EncString::try_from_optional(cipher.key)?,
1203            archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?,
1204            data: cipher.data,
1205        })
1206    }
1207}
1208
1209impl PartialCipher for CipherMiniResponseModel {
1210    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
1211        let cipher = cipher.as_ref();
1212        Ok(Cipher {
1213            id: self.id.map(CipherId::new),
1214            organization_id: self.organization_id.map(OrganizationId::new),
1215            key: EncString::try_from_optional(self.key)?,
1216            name: require!(EncString::try_from_optional(self.name)?),
1217            notes: EncString::try_from_optional(self.notes)?,
1218            r#type: require!(self.r#type).try_into()?,
1219            login: self.login.map(|l| (*l).try_into()).transpose()?,
1220            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
1221            card: self.card.map(|c| (*c).try_into()).transpose()?,
1222            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
1223            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
1224            reprompt: self
1225                .reprompt
1226                .map(|r| r.try_into())
1227                .transpose()?
1228                .unwrap_or(CipherRepromptType::None),
1229            organization_use_totp: self.organization_use_totp.unwrap_or(true),
1230            attachments: self
1231                .attachments
1232                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
1233                .transpose()?,
1234            fields: self
1235                .fields
1236                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
1237                .transpose()?,
1238            password_history: self
1239                .password_history
1240                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
1241                .transpose()?,
1242            creation_date: require!(self.creation_date)
1243                .parse()
1244                .map_err(Into::<VaultParseError>::into)?,
1245            deleted_date: self
1246                .deleted_date
1247                .map(|d| d.parse())
1248                .transpose()
1249                .map_err(Into::<VaultParseError>::into)?,
1250            revision_date: require!(self.revision_date)
1251                .parse()
1252                .map_err(Into::<VaultParseError>::into)?,
1253            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
1254            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
1255            favorite: cipher.map_or(Default::default(), |c| c.favorite),
1256            edit: cipher.map_or(Default::default(), |c| c.edit),
1257            permissions: cipher.map_or(Default::default(), |c| c.permissions),
1258            view_password: cipher.map_or(Default::default(), |c| c.view_password),
1259            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
1260            data: cipher.map_or(Default::default(), |c| c.data.clone()),
1261            collection_ids: cipher.map_or(Default::default(), |c| c.collection_ids.clone()),
1262        })
1263    }
1264}
1265
1266impl PartialCipher for CipherMiniDetailsResponseModel {
1267    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
1268        let cipher = cipher.as_ref();
1269        Ok(Cipher {
1270            id: self.id.map(CipherId::new),
1271            organization_id: self.organization_id.map(OrganizationId::new),
1272            key: EncString::try_from_optional(self.key)?,
1273            name: require!(EncString::try_from_optional(self.name)?),
1274            notes: EncString::try_from_optional(self.notes)?,
1275            r#type: require!(self.r#type).try_into()?,
1276            login: self.login.map(|l| (*l).try_into()).transpose()?,
1277            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
1278            card: self.card.map(|c| (*c).try_into()).transpose()?,
1279            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
1280            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
1281            reprompt: self
1282                .reprompt
1283                .map(|r| r.try_into())
1284                .transpose()?
1285                .unwrap_or(CipherRepromptType::None),
1286            organization_use_totp: self.organization_use_totp.unwrap_or(true),
1287            attachments: self
1288                .attachments
1289                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
1290                .transpose()?,
1291            fields: self
1292                .fields
1293                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
1294                .transpose()?,
1295            password_history: self
1296                .password_history
1297                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
1298                .transpose()?,
1299            creation_date: require!(self.creation_date)
1300                .parse()
1301                .map_err(Into::<VaultParseError>::into)?,
1302            deleted_date: self
1303                .deleted_date
1304                .map(|d| d.parse())
1305                .transpose()
1306                .map_err(Into::<VaultParseError>::into)?,
1307            revision_date: require!(self.revision_date)
1308                .parse()
1309                .map_err(Into::<VaultParseError>::into)?,
1310            collection_ids: self
1311                .collection_ids
1312                .into_iter()
1313                .flatten()
1314                .map(CollectionId::new)
1315                .collect(),
1316            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
1317            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
1318            favorite: cipher.map_or(Default::default(), |c| c.favorite),
1319            edit: cipher.map_or(Default::default(), |c| c.edit),
1320            permissions: cipher.map_or(Default::default(), |c| c.permissions),
1321            view_password: cipher.map_or(Default::default(), |c| c.view_password),
1322            data: cipher.map_or(Default::default(), |c| c.data.clone()),
1323            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
1324        })
1325    }
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330
1331    use attachment::AttachmentView;
1332    use bitwarden_core::key_management::{
1333        create_test_crypto_with_user_and_org_key, create_test_crypto_with_user_key,
1334    };
1335    use bitwarden_crypto::SymmetricCryptoKey;
1336
1337    use super::*;
1338    use crate::{Fido2Credential, PasswordHistoryView, login::Fido2CredentialListView};
1339
1340    // Test constants for encrypted strings
1341    const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk=";
1342    const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=";
1343    const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
1344    const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=";
1345    const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=";
1346    const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
1347    const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69";
1348
1349    fn generate_cipher() -> CipherView {
1350        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
1351        CipherView {
1352            r#type: CipherType::Login,
1353            login: Some(LoginView {
1354                username: Some("test_username".to_string()),
1355                password: Some("test_password".to_string()),
1356                password_revision_date: None,
1357                uris: None,
1358                totp: None,
1359                autofill_on_page_load: None,
1360                fido2_credentials: None,
1361            }),
1362            id: Some(test_id),
1363            organization_id: None,
1364            folder_id: None,
1365            collection_ids: vec![],
1366            key: None,
1367            name: "My test login".to_string(),
1368            notes: None,
1369            identity: None,
1370            card: None,
1371            secure_note: None,
1372            ssh_key: None,
1373            favorite: false,
1374            reprompt: CipherRepromptType::None,
1375            organization_use_totp: true,
1376            edit: true,
1377            permissions: None,
1378            view_password: true,
1379            local_data: None,
1380            attachments: None,
1381            attachment_decryption_failures: None,
1382            fields: None,
1383            password_history: None,
1384            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1385            deleted_date: None,
1386            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1387            archived_date: None,
1388        }
1389    }
1390
1391    fn generate_fido2(ctx: &mut KeyStoreContext<KeyIds>, key: SymmetricKeyId) -> Fido2Credential {
1392        Fido2Credential {
1393            credential_id: "123".to_string().encrypt(ctx, key).unwrap(),
1394            key_type: "public-key".to_string().encrypt(ctx, key).unwrap(),
1395            key_algorithm: "ECDSA".to_string().encrypt(ctx, key).unwrap(),
1396            key_curve: "P-256".to_string().encrypt(ctx, key).unwrap(),
1397            key_value: "123".to_string().encrypt(ctx, key).unwrap(),
1398            rp_id: "123".to_string().encrypt(ctx, key).unwrap(),
1399            user_handle: None,
1400            user_name: None,
1401            counter: "123".to_string().encrypt(ctx, key).unwrap(),
1402            rp_name: None,
1403            user_display_name: None,
1404            discoverable: "true".to_string().encrypt(ctx, key).unwrap(),
1405            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
1406        }
1407    }
1408
1409    #[test]
1410    fn test_decrypt_cipher_list_view() {
1411        let key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
1412        let key_store = create_test_crypto_with_user_key(key);
1413
1414        let cipher = Cipher {
1415            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
1416            organization_id: None,
1417            folder_id: None,
1418            collection_ids: vec![],
1419            key: None,
1420            name: TEST_CIPHER_NAME.parse().unwrap(),
1421            notes: None,
1422            r#type: CipherType::Login,
1423            login: Some(Login {
1424                username: Some("2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=".parse().unwrap()),
1425                password: Some("2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=".parse().unwrap()),
1426                password_revision_date: None,
1427                uris: None,
1428                totp: Some("2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=".parse().unwrap()),
1429                autofill_on_page_load: None,
1430                fido2_credentials: Some(vec![generate_fido2(&mut key_store.context(), SymmetricKeyId::User)]),
1431            }),
1432            identity: None,
1433            card: None,
1434            secure_note: None,
1435            ssh_key: None,
1436            favorite: false,
1437            reprompt: CipherRepromptType::None,
1438            organization_use_totp: false,
1439            edit: true,
1440            permissions: Some(CipherPermissions {
1441                delete: false,
1442                restore: false
1443            }),
1444            view_password: true,
1445            local_data: None,
1446            attachments: None,
1447            fields: None,
1448            password_history: None,
1449            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1450            deleted_date: None,
1451            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1452            archived_date: None,
1453            data: None,
1454        };
1455
1456        let view: CipherListView = key_store.decrypt(&cipher).unwrap();
1457
1458        assert_eq!(
1459            view,
1460            CipherListView {
1461                id: cipher.id,
1462                organization_id: cipher.organization_id,
1463                folder_id: cipher.folder_id,
1464                collection_ids: cipher.collection_ids,
1465                key: cipher.key,
1466                name: "My test login".to_string(),
1467                subtitle: "test_username".to_string(),
1468                r#type: CipherListViewType::Login(LoginListView {
1469                    fido2_credentials: Some(vec![Fido2CredentialListView {
1470                        credential_id: "123".to_string(),
1471                        rp_id: "123".to_string(),
1472                        user_handle: None,
1473                        user_name: None,
1474                        user_display_name: None,
1475                        counter: "123".to_string(),
1476                    }]),
1477                    has_fido2: true,
1478                    username: Some("test_username".to_string()),
1479                    totp: cipher.login.as_ref().unwrap().totp.clone(),
1480                    uris: None,
1481                }),
1482                favorite: cipher.favorite,
1483                reprompt: cipher.reprompt,
1484                organization_use_totp: cipher.organization_use_totp,
1485                edit: cipher.edit,
1486                permissions: cipher.permissions,
1487                view_password: cipher.view_password,
1488                attachments: 0,
1489                has_old_attachments: false,
1490                creation_date: cipher.creation_date,
1491                deleted_date: cipher.deleted_date,
1492                revision_date: cipher.revision_date,
1493                copyable_fields: vec![
1494                    CopyableCipherFields::LoginUsername,
1495                    CopyableCipherFields::LoginPassword,
1496                    CopyableCipherFields::LoginTotp
1497                ],
1498                local_data: None,
1499                archived_date: cipher.archived_date,
1500                #[cfg(feature = "wasm")]
1501                notes: None,
1502                #[cfg(feature = "wasm")]
1503                fields: None,
1504                #[cfg(feature = "wasm")]
1505                attachment_names: None,
1506            }
1507        )
1508    }
1509
1510    #[test]
1511    fn test_generate_cipher_key() {
1512        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1513        let key_store = create_test_crypto_with_user_key(key);
1514
1515        let original_cipher = generate_cipher();
1516
1517        // Check that the cipher gets encrypted correctly without it's own key
1518        let cipher = generate_cipher();
1519        let no_key_cipher_enc = key_store.encrypt(cipher).unwrap();
1520        let no_key_cipher_dec: CipherView = key_store.decrypt(&no_key_cipher_enc).unwrap();
1521        assert!(no_key_cipher_dec.key.is_none());
1522        assert_eq!(no_key_cipher_dec.name, original_cipher.name);
1523
1524        let mut cipher = generate_cipher();
1525        cipher
1526            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
1527            .unwrap();
1528
1529        // Check that the cipher gets encrypted correctly when it's assigned it's own key
1530        let key_cipher_enc = key_store.encrypt(cipher).unwrap();
1531        let key_cipher_dec: CipherView = key_store.decrypt(&key_cipher_enc).unwrap();
1532        assert!(key_cipher_dec.key.is_some());
1533        assert_eq!(key_cipher_dec.name, original_cipher.name);
1534    }
1535
1536    #[test]
1537    fn test_generate_cipher_key_when_a_cipher_key_already_exists() {
1538        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1539        let key_store = create_test_crypto_with_user_key(key);
1540
1541        let mut original_cipher = generate_cipher();
1542        {
1543            let mut ctx = key_store.context();
1544            let cipher_key = ctx.generate_symmetric_key();
1545
1546            original_cipher.key = Some(
1547                ctx.wrap_symmetric_key(SymmetricKeyId::User, cipher_key)
1548                    .unwrap(),
1549            );
1550        }
1551
1552        original_cipher
1553            .generate_cipher_key(&mut key_store.context(), original_cipher.key_identifier())
1554            .unwrap();
1555
1556        // Make sure that the cipher key is decryptable
1557        let wrapped_key = original_cipher.key.unwrap();
1558        let mut ctx = key_store.context();
1559        ctx.unwrap_symmetric_key(SymmetricKeyId::User, &wrapped_key)
1560            .unwrap();
1561    }
1562
1563    #[test]
1564    fn test_generate_cipher_key_ignores_attachments_without_key() {
1565        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1566        let key_store = create_test_crypto_with_user_key(key);
1567
1568        let mut cipher = generate_cipher();
1569        let attachment = AttachmentView {
1570            id: None,
1571            url: None,
1572            size: None,
1573            size_name: None,
1574            file_name: Some("Attachment test name".into()),
1575            key: None,
1576            #[cfg(feature = "wasm")]
1577            decrypted_key: None,
1578        };
1579        cipher.attachments = Some(vec![attachment]);
1580
1581        cipher
1582            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
1583            .unwrap();
1584        assert!(cipher.attachments.unwrap()[0].key.is_none());
1585    }
1586
1587    #[test]
1588    fn test_reencrypt_cipher_key() {
1589        let old_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1590        let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1591        let key_store = create_test_crypto_with_user_key(old_key);
1592        let mut ctx = key_store.context_mut();
1593
1594        let mut cipher = generate_cipher();
1595        cipher
1596            .generate_cipher_key(&mut ctx, cipher.key_identifier())
1597            .unwrap();
1598
1599        // Re-encrypt the cipher key with a new wrapping key
1600        let new_key_id = ctx.add_local_symmetric_key(new_key);
1601
1602        cipher.reencrypt_cipher_keys(&mut ctx, new_key_id).unwrap();
1603
1604        // Check that the cipher key can be unwrapped with the new key
1605        assert!(cipher.key.is_some());
1606        assert!(
1607            ctx.unwrap_symmetric_key(new_key_id, &cipher.key.unwrap())
1608                .is_ok()
1609        );
1610    }
1611
1612    #[test]
1613    fn test_reencrypt_cipher_key_ignores_missing_key() {
1614        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1615        let key_store = create_test_crypto_with_user_key(key);
1616        let mut ctx = key_store.context_mut();
1617        let mut cipher = generate_cipher();
1618
1619        // The cipher does not have a key, so re-encryption should not add one
1620        let new_cipher_key = ctx.generate_symmetric_key();
1621        cipher
1622            .reencrypt_cipher_keys(&mut ctx, new_cipher_key)
1623            .unwrap();
1624
1625        // Check that the cipher key is still None
1626        assert!(cipher.key.is_none());
1627    }
1628
1629    #[test]
1630    fn test_move_user_cipher_to_org() {
1631        let org = OrganizationId::new_v4();
1632        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1633        let org_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1634        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
1635
1636        // Create a cipher with a user key
1637        let mut cipher = generate_cipher();
1638        cipher
1639            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
1640            .unwrap();
1641
1642        cipher
1643            .move_to_organization(&mut key_store.context(), org)
1644            .unwrap();
1645        assert_eq!(cipher.organization_id, Some(org));
1646
1647        // Check that the cipher can be encrypted/decrypted with the new org key
1648        let cipher_enc = key_store.encrypt(cipher).unwrap();
1649        let cipher_dec: CipherView = key_store.decrypt(&cipher_enc).unwrap();
1650
1651        assert_eq!(cipher_dec.name, "My test login");
1652    }
1653
1654    #[test]
1655    fn test_move_user_cipher_to_org_manually() {
1656        let org = OrganizationId::new_v4();
1657        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1658        let org_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1659        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
1660
1661        // Create a cipher with a user key
1662        let mut cipher = generate_cipher();
1663        cipher
1664            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
1665            .unwrap();
1666
1667        cipher.organization_id = Some(org);
1668
1669        // Check that the cipher can not be encrypted, as the
1670        // cipher key is tied to the user key and not the org key
1671        assert!(key_store.encrypt(cipher).is_err());
1672    }
1673
1674    #[test]
1675    fn test_move_user_cipher_with_attachment_without_key_to_org() {
1676        let org = OrganizationId::new_v4();
1677        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1678        let org_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1679        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
1680
1681        let mut cipher = generate_cipher();
1682        let attachment = AttachmentView {
1683            id: None,
1684            url: None,
1685            size: None,
1686            size_name: None,
1687            file_name: Some("Attachment test name".into()),
1688            key: None,
1689            #[cfg(feature = "wasm")]
1690            decrypted_key: None,
1691        };
1692        cipher.attachments = Some(vec![attachment]);
1693
1694        // Neither cipher nor attachment have keys, so the cipher can't be moved
1695        assert!(
1696            cipher
1697                .move_to_organization(&mut key_store.context(), org)
1698                .is_err()
1699        );
1700    }
1701
1702    #[test]
1703    fn test_move_user_cipher_with_attachment_with_key_to_org() {
1704        let org = OrganizationId::new_v4();
1705        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1706        let org_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1707        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
1708        let org_key = SymmetricKeyId::Organization(org);
1709
1710        // Attachment has a key that is encrypted with the user key, as the cipher has no key itself
1711        let (attachment_key_enc, attachment_key_val) = {
1712            let mut ctx = key_store.context();
1713            let attachment_key = ctx.generate_symmetric_key();
1714            let attachment_key_enc = ctx
1715                .wrap_symmetric_key(SymmetricKeyId::User, attachment_key)
1716                .unwrap();
1717            #[allow(deprecated)]
1718            let attachment_key_val = ctx
1719                .dangerous_get_symmetric_key(attachment_key)
1720                .unwrap()
1721                .clone();
1722
1723            (attachment_key_enc, attachment_key_val)
1724        };
1725
1726        let mut cipher = generate_cipher();
1727        let attachment = AttachmentView {
1728            id: None,
1729            url: None,
1730            size: None,
1731            size_name: None,
1732            file_name: Some("Attachment test name".into()),
1733            key: Some(attachment_key_enc),
1734            #[cfg(feature = "wasm")]
1735            decrypted_key: None,
1736        };
1737        cipher.attachments = Some(vec![attachment]);
1738        let cred = generate_fido2(&mut key_store.context(), SymmetricKeyId::User);
1739        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred]);
1740
1741        cipher
1742            .move_to_organization(&mut key_store.context(), org)
1743            .unwrap();
1744
1745        assert!(cipher.key.is_none());
1746
1747        // Check that the attachment key has been re-encrypted with the org key,
1748        // and the value matches with the original attachment key
1749        let new_attachment_key = cipher.attachments.unwrap()[0].key.clone().unwrap();
1750        let mut ctx = key_store.context();
1751        let new_attachment_key_id = ctx
1752            .unwrap_symmetric_key(org_key, &new_attachment_key)
1753            .unwrap();
1754        #[allow(deprecated)]
1755        let new_attachment_key_dec = ctx
1756            .dangerous_get_symmetric_key(new_attachment_key_id)
1757            .unwrap();
1758
1759        assert_eq!(*new_attachment_key_dec, attachment_key_val);
1760
1761        let cred2: Fido2CredentialFullView = cipher
1762            .login
1763            .unwrap()
1764            .fido2_credentials
1765            .unwrap()
1766            .first()
1767            .unwrap()
1768            .decrypt(&mut key_store.context(), org_key)
1769            .unwrap();
1770
1771        assert_eq!(cred2.credential_id, "123");
1772    }
1773
1774    #[test]
1775    fn test_move_user_cipher_with_key_with_attachment_with_key_to_org() {
1776        let org = OrganizationId::new_v4();
1777        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1778        let org_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
1779        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
1780        let org_key = SymmetricKeyId::Organization(org);
1781
1782        let mut ctx = key_store.context();
1783
1784        let cipher_key = ctx.generate_symmetric_key();
1785        let cipher_key_enc = ctx
1786            .wrap_symmetric_key(SymmetricKeyId::User, cipher_key)
1787            .unwrap();
1788
1789        // Attachment has a key that is encrypted with the cipher key
1790        let attachment_key = ctx.generate_symmetric_key();
1791        let attachment_key_enc = ctx.wrap_symmetric_key(cipher_key, attachment_key).unwrap();
1792
1793        let mut cipher = generate_cipher();
1794        cipher.key = Some(cipher_key_enc);
1795
1796        let attachment = AttachmentView {
1797            id: None,
1798            url: None,
1799            size: None,
1800            size_name: None,
1801            file_name: Some("Attachment test name".into()),
1802            key: Some(attachment_key_enc.clone()),
1803            #[cfg(feature = "wasm")]
1804            decrypted_key: None,
1805        };
1806        cipher.attachments = Some(vec![attachment]);
1807
1808        let cred = generate_fido2(&mut ctx, cipher_key);
1809        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred.clone()]);
1810
1811        cipher.move_to_organization(&mut ctx, org).unwrap();
1812
1813        // Check that the cipher key has been re-encrypted with the org key,
1814        let wrapped_new_cipher_key = cipher.key.clone().unwrap();
1815        let new_cipher_key_dec = ctx
1816            .unwrap_symmetric_key(org_key, &wrapped_new_cipher_key)
1817            .unwrap();
1818        #[allow(deprecated)]
1819        let new_cipher_key_dec = ctx.dangerous_get_symmetric_key(new_cipher_key_dec).unwrap();
1820        #[allow(deprecated)]
1821        let cipher_key_val = ctx.dangerous_get_symmetric_key(cipher_key).unwrap();
1822
1823        assert_eq!(new_cipher_key_dec, cipher_key_val);
1824
1825        // Check that the attachment key hasn't changed
1826        assert_eq!(
1827            cipher.attachments.unwrap()[0]
1828                .key
1829                .as_ref()
1830                .unwrap()
1831                .to_string(),
1832            attachment_key_enc.to_string()
1833        );
1834
1835        let cred2: Fido2Credential = cipher
1836            .login
1837            .unwrap()
1838            .fido2_credentials
1839            .unwrap()
1840            .first()
1841            .unwrap()
1842            .clone();
1843
1844        assert_eq!(
1845            cred2.credential_id.to_string(),
1846            cred.credential_id.to_string()
1847        );
1848    }
1849
1850    #[test]
1851    fn test_decrypt_fido2_private_key() {
1852        let key_store =
1853            create_test_crypto_with_user_key(SymmetricCryptoKey::make_aes256_cbc_hmac_key());
1854        let mut ctx = key_store.context();
1855
1856        let mut cipher_view = generate_cipher();
1857        cipher_view
1858            .generate_cipher_key(&mut ctx, cipher_view.key_identifier())
1859            .unwrap();
1860
1861        let key_id = cipher_view.key_identifier();
1862        let ciphers_key = Cipher::decrypt_cipher_key(&mut ctx, key_id, &cipher_view.key).unwrap();
1863
1864        let fido2_credential = generate_fido2(&mut ctx, ciphers_key);
1865
1866        cipher_view.login.as_mut().unwrap().fido2_credentials =
1867            Some(vec![fido2_credential.clone()]);
1868
1869        let decrypted_key_value = cipher_view.decrypt_fido2_private_key(&mut ctx).unwrap();
1870        assert_eq!(decrypted_key_value, "123");
1871    }
1872
1873    #[test]
1874    fn test_password_history_on_password_change() {
1875        use chrono::Utc;
1876
1877        let original_cipher = generate_cipher();
1878        let mut new_cipher = generate_cipher();
1879
1880        // Change password
1881        if let Some(ref mut login) = new_cipher.login {
1882            login.password = Some("new_password123".to_string());
1883        }
1884
1885        let start = Utc::now();
1886        new_cipher.update_password_history(&original_cipher);
1887        let end = Utc::now();
1888
1889        assert!(new_cipher.password_history.is_some());
1890        let history = new_cipher.password_history.unwrap();
1891        assert_eq!(history.len(), 1);
1892        assert_eq!(history[0].password, "test_password");
1893        assert!(
1894            history[0].last_used_date >= start && history[0].last_used_date <= end,
1895            "last_used_date was not set properly"
1896        );
1897    }
1898
1899    #[test]
1900    fn test_password_history_on_unchanged_password() {
1901        let original_cipher = generate_cipher();
1902        let mut new_cipher = generate_cipher();
1903
1904        new_cipher.update_password_history(&original_cipher);
1905
1906        // Password history should be empty since password didn't change
1907        assert!(
1908            new_cipher.password_history.is_none()
1909                || new_cipher.password_history.as_ref().unwrap().is_empty()
1910        );
1911    }
1912
1913    #[test]
1914    fn test_password_history_is_preserved() {
1915        use chrono::TimeZone;
1916
1917        let mut original_cipher = generate_cipher();
1918        original_cipher.password_history = Some(
1919            (0..4)
1920                .map(|i| PasswordHistoryView {
1921                    password: format!("old_password_{}", i),
1922                    last_used_date: chrono::Utc
1923                        .with_ymd_and_hms(2025, i + 1, i + 1, i, i, i)
1924                        .unwrap(),
1925                })
1926                .collect(),
1927        );
1928
1929        let mut new_cipher = generate_cipher();
1930
1931        new_cipher.update_password_history(&original_cipher);
1932
1933        assert!(new_cipher.password_history.is_some());
1934        let history = new_cipher.password_history.unwrap();
1935        assert_eq!(history.len(), 4);
1936
1937        assert_eq!(history[0].password, "old_password_0");
1938        assert_eq!(
1939            history[0].last_used_date,
1940            chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
1941        );
1942        assert_eq!(history[1].password, "old_password_1");
1943        assert_eq!(
1944            history[1].last_used_date,
1945            chrono::Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
1946        );
1947        assert_eq!(history[2].password, "old_password_2");
1948        assert_eq!(
1949            history[2].last_used_date,
1950            chrono::Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
1951        );
1952        assert_eq!(history[3].password, "old_password_3");
1953        assert_eq!(
1954            history[3].last_used_date,
1955            chrono::Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
1956        );
1957    }
1958
1959    #[test]
1960    fn test_populate_cipher_types_login_with_valid_data() {
1961        let mut cipher = Cipher {
1962            id: Some(TEST_UUID.parse().unwrap()),
1963            organization_id: None,
1964            folder_id: None,
1965            collection_ids: vec![],
1966            key: None,
1967            name: TEST_CIPHER_NAME.parse().unwrap(),
1968            notes: None,
1969            r#type: CipherType::Login,
1970            login: None,
1971            identity: None,
1972            card: None,
1973            secure_note: None,
1974            ssh_key: None,
1975            favorite: false,
1976            reprompt: CipherRepromptType::None,
1977            organization_use_totp: false,
1978            edit: true,
1979            view_password: true,
1980            permissions: None,
1981            local_data: None,
1982            attachments: None,
1983            fields: None,
1984            password_history: None,
1985            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1986            deleted_date: None,
1987            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
1988            archived_date: None,
1989            data: Some(format!(
1990                r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
1991                TEST_ENC_STRING_1, TEST_ENC_STRING_2
1992            )),
1993        };
1994
1995        cipher
1996            .populate_cipher_types()
1997            .expect("populate_cipher_types failed");
1998
1999        assert!(cipher.login.is_some());
2000        let login = cipher.login.unwrap();
2001        assert_eq!(login.username.unwrap().to_string(), TEST_ENC_STRING_1);
2002        assert_eq!(login.password.unwrap().to_string(), TEST_ENC_STRING_2);
2003    }
2004
2005    #[test]
2006    fn test_populate_cipher_types_secure_note() {
2007        let mut cipher = Cipher {
2008            id: Some(TEST_UUID.parse().unwrap()),
2009            organization_id: None,
2010            folder_id: None,
2011            collection_ids: vec![],
2012            key: None,
2013            name: TEST_CIPHER_NAME.parse().unwrap(),
2014            notes: None,
2015            r#type: CipherType::SecureNote,
2016            login: None,
2017            identity: None,
2018            card: None,
2019            secure_note: None,
2020            ssh_key: None,
2021            favorite: false,
2022            reprompt: CipherRepromptType::None,
2023            organization_use_totp: false,
2024            edit: true,
2025            view_password: true,
2026            permissions: None,
2027            local_data: None,
2028            attachments: None,
2029            fields: None,
2030            password_history: None,
2031            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2032            deleted_date: None,
2033            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2034            archived_date: None,
2035            data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()),
2036        };
2037
2038        cipher
2039            .populate_cipher_types()
2040            .expect("populate_cipher_types failed");
2041
2042        assert!(cipher.secure_note.is_some());
2043    }
2044
2045    #[test]
2046    fn test_populate_cipher_types_card() {
2047        let mut cipher = Cipher {
2048            id: Some(TEST_UUID.parse().unwrap()),
2049            organization_id: None,
2050            folder_id: None,
2051            collection_ids: vec![],
2052            key: None,
2053            name: TEST_CIPHER_NAME.parse().unwrap(),
2054            notes: None,
2055            r#type: CipherType::Card,
2056            login: None,
2057            identity: None,
2058            card: None,
2059            secure_note: None,
2060            ssh_key: None,
2061            favorite: false,
2062            reprompt: CipherRepromptType::None,
2063            organization_use_totp: false,
2064            edit: true,
2065            view_password: true,
2066            permissions: None,
2067            local_data: None,
2068            attachments: None,
2069            fields: None,
2070            password_history: None,
2071            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2072            deleted_date: None,
2073            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2074            archived_date: None,
2075            data: Some(format!(
2076                r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
2077                TEST_ENC_STRING_1,
2078                TEST_ENC_STRING_2,
2079                TEST_ENC_STRING_3,
2080                TEST_ENC_STRING_4,
2081                TEST_ENC_STRING_5,
2082                TEST_ENC_STRING_1
2083            )),
2084        };
2085
2086        cipher
2087            .populate_cipher_types()
2088            .expect("populate_cipher_types failed");
2089
2090        assert!(cipher.card.is_some());
2091        let card = cipher.card.unwrap();
2092        assert_eq!(
2093            card.cardholder_name.as_ref().unwrap().to_string(),
2094            TEST_ENC_STRING_1
2095        );
2096        assert_eq!(card.number.as_ref().unwrap().to_string(), TEST_ENC_STRING_2);
2097        assert_eq!(
2098            card.exp_month.as_ref().unwrap().to_string(),
2099            TEST_ENC_STRING_3
2100        );
2101        assert_eq!(
2102            card.exp_year.as_ref().unwrap().to_string(),
2103            TEST_ENC_STRING_4
2104        );
2105        assert_eq!(card.code.as_ref().unwrap().to_string(), TEST_ENC_STRING_5);
2106        assert_eq!(card.brand.as_ref().unwrap().to_string(), TEST_ENC_STRING_1);
2107    }
2108
2109    #[test]
2110    fn test_populate_cipher_types_identity() {
2111        let mut cipher = Cipher {
2112            id: Some(TEST_UUID.parse().unwrap()),
2113            organization_id: None,
2114            folder_id: None,
2115            collection_ids: vec![],
2116            key: None,
2117            name: TEST_CIPHER_NAME.parse().unwrap(),
2118            notes: None,
2119            r#type: CipherType::Identity,
2120            login: None,
2121            identity: None,
2122            card: None,
2123            secure_note: None,
2124            ssh_key: None,
2125            favorite: false,
2126            reprompt: CipherRepromptType::None,
2127            organization_use_totp: false,
2128            edit: true,
2129            view_password: true,
2130            permissions: None,
2131            local_data: None,
2132            attachments: None,
2133            fields: None,
2134            password_history: None,
2135            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2136            deleted_date: None,
2137            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2138            archived_date: None,
2139            data: Some(format!(
2140                r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#,
2141                TEST_ENC_STRING_1,
2142                TEST_ENC_STRING_2,
2143                TEST_ENC_STRING_3,
2144                TEST_ENC_STRING_4,
2145                TEST_ENC_STRING_5,
2146                TEST_ENC_STRING_1,
2147                TEST_ENC_STRING_2,
2148                TEST_ENC_STRING_3,
2149                TEST_ENC_STRING_4,
2150                TEST_ENC_STRING_5
2151            )),
2152        };
2153
2154        cipher
2155            .populate_cipher_types()
2156            .expect("populate_cipher_types failed");
2157
2158        assert!(cipher.identity.is_some());
2159        let identity = cipher.identity.unwrap();
2160        assert_eq!(
2161            identity.first_name.as_ref().unwrap().to_string(),
2162            TEST_ENC_STRING_1
2163        );
2164        assert_eq!(
2165            identity.last_name.as_ref().unwrap().to_string(),
2166            TEST_ENC_STRING_2
2167        );
2168        assert_eq!(
2169            identity.email.as_ref().unwrap().to_string(),
2170            TEST_ENC_STRING_3
2171        );
2172        assert_eq!(
2173            identity.phone.as_ref().unwrap().to_string(),
2174            TEST_ENC_STRING_4
2175        );
2176        assert_eq!(
2177            identity.company.as_ref().unwrap().to_string(),
2178            TEST_ENC_STRING_5
2179        );
2180        assert_eq!(
2181            identity.address1.as_ref().unwrap().to_string(),
2182            TEST_ENC_STRING_1
2183        );
2184        assert_eq!(
2185            identity.city.as_ref().unwrap().to_string(),
2186            TEST_ENC_STRING_2
2187        );
2188        assert_eq!(
2189            identity.state.as_ref().unwrap().to_string(),
2190            TEST_ENC_STRING_3
2191        );
2192        assert_eq!(
2193            identity.postal_code.as_ref().unwrap().to_string(),
2194            TEST_ENC_STRING_4
2195        );
2196        assert_eq!(
2197            identity.country.as_ref().unwrap().to_string(),
2198            TEST_ENC_STRING_5
2199        );
2200    }
2201
2202    #[test]
2203
2204    fn test_password_history_with_hidden_fields() {
2205        let mut original_cipher = generate_cipher();
2206        original_cipher.fields = Some(vec![FieldView {
2207            name: Some("Secret Key".to_string()),
2208            value: Some("old_secret_value".to_string()),
2209            r#type: crate::FieldType::Hidden,
2210            linked_id: None,
2211        }]);
2212
2213        let mut new_cipher = generate_cipher();
2214        new_cipher.fields = Some(vec![FieldView {
2215            name: Some("Secret Key".to_string()),
2216            value: Some("new_secret_value".to_string()),
2217            r#type: crate::FieldType::Hidden,
2218            linked_id: None,
2219        }]);
2220
2221        new_cipher.update_password_history(&original_cipher);
2222
2223        assert!(new_cipher.password_history.is_some());
2224        let history = new_cipher.password_history.unwrap();
2225        assert_eq!(history.len(), 1);
2226        assert_eq!(history[0].password, "Secret Key: old_secret_value");
2227    }
2228
2229    #[test]
2230    fn test_password_history_length_limit() {
2231        use crate::password_history::MAX_PASSWORD_HISTORY_ENTRIES;
2232
2233        let mut original_cipher = generate_cipher();
2234        original_cipher.password_history = Some(
2235            (0..10)
2236                .map(|i| PasswordHistoryView {
2237                    password: format!("old_password_{}", i),
2238                    last_used_date: chrono::Utc::now(),
2239                })
2240                .collect(),
2241        );
2242
2243        let mut new_cipher = original_cipher.clone();
2244        // Change password
2245        if let Some(ref mut login) = new_cipher.login {
2246            login.password = Some("brand_new_password".to_string());
2247        }
2248
2249        new_cipher.update_password_history(&original_cipher);
2250
2251        assert!(new_cipher.password_history.is_some());
2252        let history = new_cipher.password_history.unwrap();
2253
2254        // Should be limited to MAX_PASSWORD_HISTORY_ENTRIES
2255        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
2256
2257        // Most recent change (original password) should be first
2258        assert_eq!(history[0].password, "test_password");
2259        // Followed by the oldest entries from the existing history
2260        assert_eq!(history[1].password, "old_password_0");
2261        assert_eq!(history[2].password, "old_password_1");
2262        assert_eq!(history[3].password, "old_password_2");
2263        assert_eq!(history[4].password, "old_password_3");
2264    }
2265
2266    #[test]
2267    fn test_populate_cipher_types_ssh_key() {
2268        let mut cipher = Cipher {
2269            id: Some(TEST_UUID.parse().unwrap()),
2270            organization_id: None,
2271            folder_id: None,
2272            collection_ids: vec![],
2273            key: None,
2274            name: TEST_CIPHER_NAME.parse().unwrap(),
2275            notes: None,
2276            r#type: CipherType::SshKey,
2277            login: None,
2278            identity: None,
2279            card: None,
2280            secure_note: None,
2281            ssh_key: None,
2282            favorite: false,
2283            reprompt: CipherRepromptType::None,
2284            organization_use_totp: false,
2285            edit: true,
2286            view_password: true,
2287            permissions: None,
2288            local_data: None,
2289            attachments: None,
2290            fields: None,
2291            password_history: None,
2292            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2293            deleted_date: None,
2294            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2295            archived_date: None,
2296            data: Some(format!(
2297                r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
2298                TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3
2299            )),
2300        };
2301
2302        cipher
2303            .populate_cipher_types()
2304            .expect("populate_cipher_types failed");
2305
2306        assert!(cipher.ssh_key.is_some());
2307        let ssh_key = cipher.ssh_key.unwrap();
2308        assert_eq!(ssh_key.private_key.to_string(), TEST_ENC_STRING_1);
2309        assert_eq!(ssh_key.public_key.to_string(), TEST_ENC_STRING_2);
2310        assert_eq!(ssh_key.fingerprint.to_string(), TEST_ENC_STRING_3);
2311    }
2312
2313    #[test]
2314    fn test_populate_cipher_types_with_null_data() {
2315        let mut cipher = Cipher {
2316            id: Some(TEST_UUID.parse().unwrap()),
2317            organization_id: None,
2318            folder_id: None,
2319            collection_ids: vec![],
2320            key: None,
2321            name: TEST_CIPHER_NAME.parse().unwrap(),
2322            notes: None,
2323            r#type: CipherType::Login,
2324            login: None,
2325            identity: None,
2326            card: None,
2327            secure_note: None,
2328            ssh_key: None,
2329            favorite: false,
2330            reprompt: CipherRepromptType::None,
2331            organization_use_totp: false,
2332            edit: true,
2333            view_password: true,
2334            permissions: None,
2335            local_data: None,
2336            attachments: None,
2337            fields: None,
2338            password_history: None,
2339            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2340            deleted_date: None,
2341            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2342            archived_date: None,
2343            data: None,
2344        };
2345
2346        let result = cipher.populate_cipher_types();
2347        assert!(matches!(
2348            result,
2349            Err(VaultParseError::MissingField(MissingFieldError("data")))
2350        ));
2351    }
2352
2353    #[test]
2354    fn test_populate_cipher_types_with_invalid_json() {
2355        let mut cipher = Cipher {
2356            id: Some(TEST_UUID.parse().unwrap()),
2357            organization_id: None,
2358            folder_id: None,
2359            collection_ids: vec![],
2360            key: None,
2361            name: TEST_CIPHER_NAME.parse().unwrap(),
2362            notes: None,
2363            r#type: CipherType::Login,
2364            login: None,
2365            identity: None,
2366            card: None,
2367            secure_note: None,
2368            ssh_key: None,
2369            favorite: false,
2370            reprompt: CipherRepromptType::None,
2371            organization_use_totp: false,
2372            edit: true,
2373            view_password: true,
2374            permissions: None,
2375            local_data: None,
2376            attachments: None,
2377            fields: None,
2378            password_history: None,
2379            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2380            deleted_date: None,
2381            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2382            archived_date: None,
2383            data: Some("invalid json".to_string()),
2384        };
2385
2386        let result = cipher.populate_cipher_types();
2387
2388        assert!(matches!(result, Err(VaultParseError::SerdeJson(_))));
2389    }
2390
2391    #[test]
2392    fn test_decrypt_cipher_with_mixed_attachments() {
2393        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
2394        let key_store = create_test_crypto_with_user_key(user_key);
2395
2396        // Create properly encrypted attachments
2397        let mut ctx = key_store.context();
2398        let valid1 = "valid_file_1.txt"
2399            .encrypt(&mut ctx, SymmetricKeyId::User)
2400            .unwrap();
2401        let valid2 = "valid_file_2.txt"
2402            .encrypt(&mut ctx, SymmetricKeyId::User)
2403            .unwrap();
2404
2405        // Create corrupted attachment by encrypting with a random different key
2406        let wrong_key: SymmetricCryptoKey = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==".to_string().try_into().unwrap();
2407        let wrong_key_store = create_test_crypto_with_user_key(wrong_key);
2408        let mut wrong_ctx = wrong_key_store.context();
2409        let corrupted = "corrupted_file.txt"
2410            .encrypt(&mut wrong_ctx, SymmetricKeyId::User)
2411            .unwrap();
2412
2413        let cipher = Cipher {
2414            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
2415            organization_id: None,
2416            folder_id: None,
2417            collection_ids: vec![],
2418            key: None,
2419            name: TEST_CIPHER_NAME.parse().unwrap(),
2420            notes: None,
2421            r#type: CipherType::Login,
2422            login: None,
2423            identity: None,
2424            card: None,
2425            secure_note: None,
2426            ssh_key: None,
2427            favorite: false,
2428            reprompt: CipherRepromptType::None,
2429            organization_use_totp: false,
2430            edit: true,
2431            permissions: None,
2432            view_password: true,
2433            local_data: None,
2434            attachments: Some(vec![
2435                // Valid attachment
2436                attachment::Attachment {
2437                    id: Some("valid-attachment".to_string()),
2438                    url: Some("https://example.com/valid".to_string()),
2439                    size: Some("100".to_string()),
2440                    size_name: Some("100 Bytes".to_string()),
2441                    file_name: Some(valid1),
2442                    key: None,
2443                },
2444                // Corrupted attachment
2445                attachment::Attachment {
2446                    id: Some("corrupted-attachment".to_string()),
2447                    url: Some("https://example.com/corrupted".to_string()),
2448                    size: Some("200".to_string()),
2449                    size_name: Some("200 Bytes".to_string()),
2450                    file_name: Some(corrupted),
2451                    key: None,
2452                },
2453                // Another valid attachment
2454                attachment::Attachment {
2455                    id: Some("valid-attachment-2".to_string()),
2456                    url: Some("https://example.com/valid2".to_string()),
2457                    size: Some("150".to_string()),
2458                    size_name: Some("150 Bytes".to_string()),
2459                    file_name: Some(valid2),
2460                    key: None,
2461                },
2462            ]),
2463            fields: None,
2464            password_history: None,
2465            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2466            deleted_date: None,
2467            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2468            archived_date: None,
2469            data: None,
2470        };
2471
2472        let view: CipherView = key_store.decrypt(&cipher).unwrap();
2473
2474        // Should have 2 successful attachments
2475        assert!(view.attachments.is_some());
2476        let successes = view.attachments.as_ref().unwrap();
2477        assert_eq!(successes.len(), 2);
2478        assert_eq!(successes[0].id, Some("valid-attachment".to_string()));
2479        assert_eq!(successes[1].id, Some("valid-attachment-2".to_string()));
2480
2481        // Should have 1 failed attachment
2482        assert!(view.attachment_decryption_failures.is_some());
2483        let failures = view.attachment_decryption_failures.as_ref().unwrap();
2484        assert_eq!(failures.len(), 1);
2485        assert_eq!(failures[0].id, Some("corrupted-attachment".to_string()));
2486        assert_eq!(failures[0].file_name, None);
2487    }
2488}