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