Skip to main content

bitwarden_vault/cipher/
cipher.rs

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