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