bitwarden_vault/cipher/
cipher.rs

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