Skip to main content

bitwarden_vault/cipher/
cipher.rs

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