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, SymmetricCryptoKey, SymmetricKeyAlgorithm,
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;
22#[cfg(feature = "wasm")]
23use tsify::Tsify;
24#[cfg(feature = "wasm")]
25use wasm_bindgen::prelude::wasm_bindgen;
26
27use super::{
28    attachment, bank_account,
29    bank_account::BankAccountListView,
30    blob::{decrypt_blob_cipher, encrypt_blob_cipher_with_wrapping_key, try_parse_blob},
31    card,
32    card::CardListView,
33    cipher_permissions::CipherPermissions,
34    drivers_license, field, identity,
35    local_data::{LocalData, LocalDataView},
36    login::LoginListView,
37    passport, secure_note, ssh_key,
38};
39use crate::{
40    AttachmentView, DecryptError, EncryptError, Fido2CredentialFullView, Fido2CredentialView,
41    FieldView, FolderId, Login, LoginView, VaultParseError,
42    password_history::{self, MAX_PASSWORD_HISTORY_ENTRIES},
43};
44
45uuid_newtype!(pub CipherId);
46
47#[allow(missing_docs)]
48#[bitwarden_error(flat)]
49#[derive(Debug, Error)]
50pub enum CipherError {
51    #[error(transparent)]
52    MissingField(#[from] MissingFieldError),
53    #[error(transparent)]
54    Crypto(#[from] CryptoError),
55    #[error(transparent)]
56    Decrypt(#[from] DecryptError),
57    #[error(transparent)]
58    Encrypt(#[from] EncryptError),
59    #[error(
60        "This cipher contains attachments without keys. Those attachments will need to be reuploaded to complete the operation"
61    )]
62    AttachmentsWithoutKeys,
63    #[error("This cipher cannot be moved to the specified organization")]
64    OrganizationAlreadySet,
65    #[error(transparent)]
66    Repository(#[from] RepositoryError),
67    #[error(transparent)]
68    Chrono(#[from] chrono::ParseError),
69    #[error(transparent)]
70    SerdeJson(#[from] serde_json::Error),
71    #[error(transparent)]
72    Api(#[from] ApiError),
73}
74
75impl<T> From<bitwarden_api_api::apis::Error<T>> for CipherError {
76    fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
77        Self::Api(value.into())
78    }
79}
80
81/// Helper trait for operations on cipher types.
82pub(super) trait CipherKind {
83    /// Returns the item's subtitle.
84    fn decrypt_subtitle(
85        &self,
86        ctx: &mut KeyStoreContext<KeySlotIds>,
87        key: SymmetricKeySlotId,
88    ) -> Result<String, CryptoError>;
89
90    /// Returns a list of populated fields for the cipher.
91    fn get_copyable_fields(&self, cipher: Option<&Cipher>) -> Vec<CopyableCipherFields>;
92}
93
94#[allow(missing_docs)]
95#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
96#[repr(u8)]
97#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
98#[cfg_attr(feature = "wasm", wasm_bindgen)]
99pub enum CipherType {
100    Login = 1,
101    SecureNote = 2,
102    Card = 3,
103    Identity = 4,
104    SshKey = 5,
105    BankAccount = 6,
106    DriversLicense = 7,
107    Passport = 8,
108}
109
110#[allow(missing_docs)]
111#[derive(Clone, Copy, Default, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
112#[repr(u8)]
113#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
114#[cfg_attr(feature = "wasm", wasm_bindgen)]
115pub enum CipherRepromptType {
116    #[default]
117    None = 0,
118    Password = 1,
119}
120
121#[allow(missing_docs)]
122#[derive(Serialize, Deserialize, Debug, Clone)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
125#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
126pub struct EncryptionContext {
127    /// The Id of the user that encrypted the cipher. It should always represent a UserId, even for
128    /// Organization-owned ciphers
129    pub encrypted_for: UserId,
130    pub cipher: Cipher,
131}
132
133impl TryFrom<EncryptionContext> for CipherWithIdRequestModel {
134    type Error = CipherError;
135    fn try_from(
136        EncryptionContext {
137            cipher,
138            encrypted_for,
139        }: EncryptionContext,
140    ) -> Result<Self, Self::Error> {
141        Ok(Self {
142            id: require!(cipher.id).into(),
143            encrypted_for: Some(encrypted_for.into()),
144            r#type: Some(cipher.r#type.into()),
145            organization_id: cipher.organization_id.map(|o| o.to_string()),
146            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
147            favorite: cipher.favorite.into(),
148            reprompt: Some(cipher.reprompt.into()),
149            key: cipher.key.map(|k| k.to_string()),
150            name: cipher
151                .name
152                .as_ref()
153                .map(ToString::to_string)
154                .unwrap_or_default(),
155            notes: cipher.notes.map(|n| n.to_string()),
156            fields: Some(
157                cipher
158                    .fields
159                    .into_iter()
160                    .flatten()
161                    .map(Into::into)
162                    .collect(),
163            ),
164            password_history: Some(
165                cipher
166                    .password_history
167                    .into_iter()
168                    .flatten()
169                    .map(Into::into)
170                    .collect(),
171            ),
172            attachments: None,
173            attachments2: Some(
174                cipher
175                    .attachments
176                    .into_iter()
177                    .flatten()
178                    .filter_map(|a| {
179                        a.id.map(|id| {
180                            (
181                                id,
182                                bitwarden_api_api::models::CipherAttachmentModel {
183                                    file_name: a.file_name.map(|n| n.to_string()),
184                                    key: a.key.map(|k| k.to_string()),
185                                },
186                            )
187                        })
188                    })
189                    .collect(),
190            ),
191            login: cipher.login.map(|l| Box::new(l.into())),
192            card: cipher.card.map(|c| Box::new(c.into())),
193            identity: cipher.identity.map(|i| Box::new(i.into())),
194            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
195            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
196            bank_account: cipher.bank_account.map(|b| Box::new(b.into())),
197            drivers_license: cipher.drivers_license.map(|d| Box::new(d.into())),
198            passport: cipher.passport.map(|p| Box::new(p.into())),
199            data: cipher.data,
200            last_known_revision_date: Some(
201                cipher
202                    .revision_date
203                    .to_rfc3339_opts(SecondsFormat::Millis, true),
204            ),
205            archived_date: cipher
206                .archived_date
207                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
208        })
209    }
210}
211
212impl From<EncryptionContext> for CipherRequestModel {
213    fn from(
214        EncryptionContext {
215            cipher,
216            encrypted_for,
217        }: EncryptionContext,
218    ) -> Self {
219        Self {
220            encrypted_for: Some(encrypted_for.into()),
221            r#type: Some(cipher.r#type.into()),
222            organization_id: cipher.organization_id.map(|o| o.to_string()),
223            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
224            favorite: cipher.favorite.into(),
225            reprompt: Some(cipher.reprompt.into()),
226            key: cipher.key.map(|k| k.to_string()),
227            name: cipher
228                .name
229                .as_ref()
230                .map(ToString::to_string)
231                .unwrap_or_default(),
232            notes: cipher.notes.map(|n| n.to_string()),
233            fields: Some(
234                cipher
235                    .fields
236                    .into_iter()
237                    .flatten()
238                    .map(Into::into)
239                    .collect(),
240            ),
241            password_history: Some(
242                cipher
243                    .password_history
244                    .into_iter()
245                    .flatten()
246                    .map(Into::into)
247                    .collect(),
248            ),
249            attachments: None,
250            attachments2: Some(
251                cipher
252                    .attachments
253                    .into_iter()
254                    .flatten()
255                    .filter_map(|a| {
256                        a.id.map(|id| {
257                            (
258                                id,
259                                bitwarden_api_api::models::CipherAttachmentModel {
260                                    file_name: a.file_name.map(|n| n.to_string()),
261                                    key: a.key.map(|k| k.to_string()),
262                                },
263                            )
264                        })
265                    })
266                    .collect(),
267            ),
268            login: cipher.login.map(|l| Box::new(l.into())),
269            card: cipher.card.map(|c| Box::new(c.into())),
270            identity: cipher.identity.map(|i| Box::new(i.into())),
271            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
272            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
273            bank_account: cipher.bank_account.map(|b| Box::new(b.into())),
274            drivers_license: cipher.drivers_license.map(|d| Box::new(d.into())),
275            passport: cipher.passport.map(|p| Box::new(p.into())),
276            data: cipher.data,
277            last_known_revision_date: Some(
278                cipher
279                    .revision_date
280                    .to_rfc3339_opts(SecondsFormat::Millis, true),
281            ),
282            archived_date: cipher
283                .archived_date
284                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
285        }
286    }
287}
288
289#[allow(missing_docs)]
290#[derive(Serialize, Deserialize, Debug, Clone)]
291#[serde(rename_all = "camelCase", deny_unknown_fields)]
292#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
293#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
294pub struct Cipher {
295    pub id: Option<CipherId>,
296    pub organization_id: Option<OrganizationId>,
297    pub folder_id: Option<FolderId>,
298    pub collection_ids: Vec<CollectionId>,
299    /// More recent ciphers uses individual encryption keys to encrypt the other fields of the
300    /// Cipher.
301    pub key: Option<EncString>,
302
303    /// Encrypted item name. `None` for blob-encrypted ciphers, where the name lives inside
304    /// the sealed `data` blob; required on the legacy field-level format.
305    pub name: Option<EncString>,
306    pub notes: Option<EncString>,
307
308    pub r#type: CipherType,
309    pub login: Option<Login>,
310    pub identity: Option<identity::Identity>,
311    pub card: Option<card::Card>,
312    pub secure_note: Option<secure_note::SecureNote>,
313    pub ssh_key: Option<ssh_key::SshKey>,
314    pub bank_account: Option<bank_account::BankAccount>,
315    pub drivers_license: Option<drivers_license::DriversLicense>,
316    pub passport: Option<passport::Passport>,
317
318    pub favorite: bool,
319    pub reprompt: CipherRepromptType,
320    pub organization_use_totp: bool,
321    pub edit: bool,
322    pub permissions: Option<CipherPermissions>,
323    pub view_password: bool,
324    pub local_data: Option<LocalData>,
325
326    pub attachments: Option<Vec<attachment::Attachment>>,
327    pub fields: Option<Vec<field::Field>>,
328    pub password_history: Option<Vec<password_history::PasswordHistory>>,
329
330    pub creation_date: DateTime<Utc>,
331    pub deleted_date: Option<DateTime<Utc>>,
332    pub revision_date: DateTime<Utc>,
333    pub archived_date: Option<DateTime<Utc>>,
334    pub data: Option<String>,
335}
336
337/// Represents the result of re-wrapping a cipher key, which can be needed when changing the
338/// ownership of a cipher or rotating keys.
339pub enum CipherKeyRewrapError {
340    NoCipherKey,
341    DecryptionFailure,
342    EncryptionFailure,
343}
344
345impl Cipher {
346    /// Re-wraps the encrypted cipher-key. This should be done when moving the cipher to a new
347    /// ownership (user to org), or when rotating the owning key. This mutates the cipher's key
348    /// field if successful, otherwise returns an error. Data stays encrypted the same way and
349    /// does not need to be re-uploaded to the server.
350    pub fn rewrap_cipher_key(
351        &mut self,
352        old_key: SymmetricKeySlotId,
353        new_key: SymmetricKeySlotId,
354        ctx: &mut KeyStoreContext<KeySlotIds>,
355    ) -> Result<(), CipherKeyRewrapError> {
356        let new_cipher_key = self
357            .key
358            .as_ref()
359            .ok_or(CipherKeyRewrapError::NoCipherKey)
360            .and_then(|wrapped_cipher_key| {
361                ctx.unwrap_symmetric_key(old_key, wrapped_cipher_key)
362                    .map_err(|_| CipherKeyRewrapError::DecryptionFailure)
363            })
364            .and_then(|cipher_key| {
365                ctx.wrap_symmetric_key(new_key, cipher_key)
366                    .map_err(|_| CipherKeyRewrapError::EncryptionFailure)
367            })?;
368        self.key = Some(new_cipher_key);
369        Ok(())
370    }
371
372    /// Returns `true` if this cipher's sensitive data is stored in the sealed-blob format.
373    pub fn is_blob_encrypted(&self) -> bool {
374        try_parse_blob(self).is_some()
375    }
376}
377
378bitwarden_state::register_repository_item!(CipherId => Cipher, "Cipher");
379
380impl TryFrom<Cipher> for CipherRequestModel {
381    type Error = CryptoError;
382
383    /// Structural mapping from an encrypted [`Cipher`] to the API's expected
384    /// [`CipherRequestModel`]. No crypto — all encryption happened upstream in
385    /// `CipherView::encrypt_composite`. Callers are responsible for setting
386    /// `encrypted_for` after the conversion.
387    ///
388    /// Fails with [`CryptoError::MissingField`] if any attachment has no `id`
389    fn try_from(c: Cipher) -> Result<Self, Self::Error> {
390        let attachments2 = c
391            .attachments
392            .map(|list| {
393                list.into_iter()
394                    .map(|a| {
395                        let id = a.id.clone().ok_or(CryptoError::MissingField("id"))?;
396                        Ok::<_, CryptoError>((id, a.into()))
397                    })
398                    .collect::<Result<_, _>>()
399            })
400            .transpose()?;
401
402        Ok(CipherRequestModel {
403            encrypted_for: None,
404            r#type: Some(c.r#type.into()),
405            organization_id: c.organization_id.map(|id| id.to_string()),
406            folder_id: c.folder_id.map(|id| id.to_string()),
407            favorite: Some(c.favorite),
408            reprompt: Some(c.reprompt.into()),
409            key: c.key.map(|k| k.to_string()),
410            name: c.name.as_ref().map(ToString::to_string).unwrap_or_default(),
411            notes: c.notes.map(|n| n.to_string()),
412            login: c.login.map(|v| Box::new(v.into())),
413            card: c.card.map(|v| Box::new(v.into())),
414            identity: c.identity.map(|v| Box::new(v.into())),
415            secure_note: c.secure_note.map(|v| Box::new(v.into())),
416            ssh_key: c.ssh_key.map(|v| Box::new(v.into())),
417            bank_account: c.bank_account.map(|v| Box::new(v.into())),
418            drivers_license: c.drivers_license.map(|v| Box::new(v.into())),
419            passport: c.passport.map(|v| Box::new(v.into())),
420            fields: c.fields.map(|f| f.into_iter().map(Into::into).collect()),
421            password_history: c
422                .password_history
423                .map(|h| h.into_iter().map(Into::into).collect()),
424            attachments: None,
425            attachments2,
426            last_known_revision_date: Some(
427                c.revision_date.to_rfc3339_opts(SecondsFormat::Secs, true),
428            ),
429            archived_date: c.archived_date.map(|d| d.to_rfc3339()),
430            data: c.data,
431        })
432    }
433}
434
435#[allow(missing_docs)]
436#[derive(Serialize, Deserialize, Debug, Clone)]
437#[serde(rename_all = "camelCase", deny_unknown_fields)]
438#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
439#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
440pub struct CipherView {
441    pub id: Option<CipherId>,
442    pub organization_id: Option<OrganizationId>,
443    pub folder_id: Option<FolderId>,
444    pub collection_ids: Vec<CollectionId>,
445
446    /// Temporary, required to support re-encrypting existing items.
447    pub key: Option<EncString>,
448
449    pub name: String,
450    pub notes: Option<String>,
451
452    pub r#type: CipherType,
453    pub login: Option<LoginView>,
454    pub identity: Option<identity::IdentityView>,
455    pub card: Option<card::CardView>,
456    pub secure_note: Option<secure_note::SecureNoteView>,
457    pub ssh_key: Option<ssh_key::SshKeyView>,
458    pub bank_account: Option<bank_account::BankAccountView>,
459    pub drivers_license: Option<drivers_license::DriversLicenseView>,
460    pub passport: Option<passport::PassportView>,
461
462    pub favorite: bool,
463    pub reprompt: CipherRepromptType,
464    pub organization_use_totp: bool,
465    pub edit: bool,
466    pub permissions: Option<CipherPermissions>,
467    pub view_password: bool,
468    pub local_data: Option<LocalDataView>,
469
470    pub attachments: Option<Vec<attachment::AttachmentView>>,
471    /// Attachments that failed to decrypt. Only present when there are decryption failures.
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub attachment_decryption_failures: Option<Vec<attachment::AttachmentView>>,
474    pub fields: Option<Vec<field::FieldView>>,
475    pub password_history: Option<Vec<password_history::PasswordHistoryView>>,
476    pub creation_date: DateTime<Utc>,
477    pub deleted_date: Option<DateTime<Utc>>,
478    pub revision_date: DateTime<Utc>,
479    pub archived_date: Option<DateTime<Utc>>,
480}
481
482#[allow(missing_docs)]
483#[derive(Serialize, Deserialize, Debug, PartialEq)]
484#[serde(rename_all = "camelCase", deny_unknown_fields)]
485#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
486#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
487pub enum CipherListViewType {
488    Login(LoginListView),
489    SecureNote,
490    Card(CardListView),
491    Identity,
492    SshKey,
493    BankAccount(BankAccountListView),
494    Passport,
495    DriversLicense,
496}
497
498/// Available fields on a cipher and can be copied from a the list view in the UI.
499#[derive(Serialize, Deserialize, Debug, PartialEq)]
500#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
501#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
502pub enum CopyableCipherFields {
503    LoginUsername,
504    LoginPassword,
505    LoginTotp,
506    CardNumber,
507    CardSecurityCode,
508    IdentityUsername,
509    IdentityEmail,
510    IdentityPhone,
511    IdentityAddress,
512    SshKey,
513    SecureNotes,
514    BankAccountNameOnAccount,
515    BankAccountAccountNumber,
516    BankAccountRoutingNumber,
517    BankAccountBranchNumber,
518    BankAccountPin,
519    BankAccountIban,
520    BankAccountSwift,
521    PassportGivenName,
522    PassportSurname,
523    PassportPassportNumber,
524    PassportNationalIdentificationNumber,
525    DriversLicenseFirstName,
526    DriversLicenseMiddleName,
527    DriversLicenseLastName,
528    DriversLicenseLicenseNumber,
529}
530
531#[allow(missing_docs)]
532#[derive(Serialize, Deserialize, Debug, PartialEq)]
533#[serde(rename_all = "camelCase", deny_unknown_fields)]
534#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
535#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
536pub struct CipherListView {
537    pub id: Option<CipherId>,
538    pub organization_id: Option<OrganizationId>,
539    pub folder_id: Option<FolderId>,
540    pub collection_ids: Vec<CollectionId>,
541
542    /// Temporary, required to support calculating TOTP from CipherListView.
543    pub key: Option<EncString>,
544
545    pub name: String,
546    pub subtitle: String,
547
548    pub r#type: CipherListViewType,
549
550    pub favorite: bool,
551    pub reprompt: CipherRepromptType,
552    pub organization_use_totp: bool,
553    pub edit: bool,
554    pub permissions: Option<CipherPermissions>,
555
556    pub view_password: bool,
557
558    /// The number of attachments
559    pub attachments: u32,
560    /// Indicates if the cipher has old attachments that need to be re-uploaded
561    pub has_old_attachments: bool,
562
563    pub creation_date: DateTime<Utc>,
564    pub deleted_date: Option<DateTime<Utc>>,
565    pub revision_date: DateTime<Utc>,
566    pub archived_date: Option<DateTime<Utc>>,
567
568    /// Hints for the presentation layer for which fields can be copied.
569    pub copyable_fields: Vec<CopyableCipherFields>,
570
571    pub local_data: Option<LocalDataView>,
572
573    /// Decrypted cipher notes for search indexing.
574    #[cfg(feature = "wasm")]
575    pub notes: Option<String>,
576    /// Decrypted cipher fields for search indexing.
577    /// Only includes name and value (for text fields only).
578    #[cfg(feature = "wasm")]
579    pub fields: Option<Vec<field::FieldListView>>,
580    /// Decrypted attachment filenames for search indexing.
581    #[cfg(feature = "wasm")]
582    pub attachment_names: Option<Vec<String>>,
583}
584
585/// Represents the result of decrypting a list of ciphers.
586///
587/// This struct contains two vectors: `successes` and `failures`.
588/// `successes` contains the decrypted `CipherListView` objects,
589/// while `failures` contains the original `Cipher` objects that failed to decrypt.
590#[derive(Serialize, Deserialize, Debug)]
591#[serde(rename_all = "camelCase", deny_unknown_fields)]
592#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
593#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
594pub struct DecryptCipherListResult {
595    /// The decrypted `CipherListView` objects.
596    pub successes: Vec<CipherListView>,
597    /// The original `Cipher` objects that failed to decrypt.
598    pub failures: Vec<Cipher>,
599}
600
601/// Represents the result of decrypting a list of ciphers.
602///
603/// This struct contains two vectors: `successes` and `failures`.
604/// `successes` contains the decrypted `CipherView` objects,
605/// while `failures` contains the original `Cipher` objects that failed to decrypt.
606#[derive(Serialize, Deserialize, Debug)]
607#[serde(rename_all = "camelCase", deny_unknown_fields)]
608#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
609#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
610pub struct DecryptCipherResult {
611    /// The decrypted `CipherView` objects.
612    pub successes: Vec<CipherView>,
613    /// The original `Cipher` objects that failed to decrypt.
614    pub failures: Vec<Cipher>,
615}
616
617/// Represents the result of fetching and decrypting all ciphers for an organization.
618///
619/// Contains the encrypted ciphers from the API alongside their decrypted list views.
620#[derive(Serialize, Deserialize, Debug)]
621#[serde(rename_all = "camelCase", deny_unknown_fields)]
622#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
623#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
624pub struct ListOrganizationCiphersResult {
625    /// All encrypted ciphers returned from the API.
626    pub ciphers: Vec<Cipher>,
627    /// Successfully decrypted `CipherListView` objects.
628    pub list_views: Vec<CipherListView>,
629}
630
631impl CipherListView {
632    pub(crate) fn get_totp_key(
633        self,
634        ctx: &mut KeyStoreContext<KeySlotIds>,
635    ) -> Result<Option<String>, CryptoError> {
636        let key = self.key_identifier();
637        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
638
639        let totp = match self.r#type {
640            CipherListViewType::Login(LoginListView { totp, .. }) => {
641                totp.map(|t| t.decrypt(ctx, ciphers_key)).transpose()?
642            }
643            _ => None,
644        };
645
646        Ok(totp)
647    }
648}
649
650// ⚠️ CONTRACT VIOLATION of `bitwarden_crypto::CompositeEncryptable`: `CipherView` retains key-bound
651// ciphertext (`key`, the cipher content-encryption key wrapped under the decrypting key) and copies
652// it through unchanged (`key: cipher_view.key` below) instead of re-wrapping it under `key`. As a
653// result decrypt(K) -> encrypt(K1) -> decrypt(K1) does NOT round-trip.
654impl CipherView {
655    fn encrypt_legacy_field_encryption(
656        &self,
657        ctx: &mut KeyStoreContext<KeySlotIds>,
658        key: SymmetricKeySlotId,
659    ) -> Result<Cipher, CryptoError> {
660        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
661
662        let mut cipher_view = self.clone();
663        cipher_view.generate_checksums();
664
665        Ok(Cipher {
666            id: cipher_view.id,
667            organization_id: cipher_view.organization_id,
668            folder_id: cipher_view.folder_id,
669            collection_ids: cipher_view.collection_ids,
670            // ⚠️ pass-through of wrapped key-bound ciphertext — see the contract-violation note
671            // above.
672            key: cipher_view.key,
673            name: Some(cipher_view.name.encrypt(ctx, ciphers_key)?),
674            notes: cipher_view.notes.encrypt(ctx, ciphers_key)?,
675            r#type: cipher_view.r#type,
676            login: cipher_view.login.encrypt_composite(ctx, ciphers_key)?,
677            identity: cipher_view.identity.encrypt_composite(ctx, ciphers_key)?,
678            card: cipher_view.card.encrypt_composite(ctx, ciphers_key)?,
679            secure_note: cipher_view
680                .secure_note
681                .encrypt_composite(ctx, ciphers_key)?,
682            ssh_key: cipher_view.ssh_key.encrypt_composite(ctx, ciphers_key)?,
683            bank_account: cipher_view
684                .bank_account
685                .encrypt_composite(ctx, ciphers_key)?,
686            drivers_license: cipher_view
687                .drivers_license
688                .encrypt_composite(ctx, ciphers_key)?,
689            passport: cipher_view.passport.encrypt_composite(ctx, ciphers_key)?,
690            favorite: cipher_view.favorite,
691            reprompt: cipher_view.reprompt,
692            organization_use_totp: cipher_view.organization_use_totp,
693            edit: cipher_view.edit,
694            view_password: cipher_view.view_password,
695            local_data: cipher_view.local_data.encrypt_composite(ctx, ciphers_key)?,
696            attachments: cipher_view
697                .attachments
698                .encrypt_composite(ctx, ciphers_key)?,
699            fields: cipher_view.fields.encrypt_composite(ctx, ciphers_key)?,
700            password_history: cipher_view
701                .password_history
702                .encrypt_composite(ctx, ciphers_key)?,
703            creation_date: cipher_view.creation_date,
704            deleted_date: cipher_view.deleted_date,
705            revision_date: cipher_view.revision_date,
706            permissions: cipher_view.permissions,
707            archived_date: cipher_view.archived_date,
708            data: None, // TODO: Do we need to repopulate this on this on the cipher?
709        })
710    }
711}
712
713/// Lenient `Cipher` → `CipherView` decryption body. Used by the default
714/// [`Decryptable`] impl on `Cipher` when the cipher is in the legacy field-level
715/// format. Callers funnel through that impl, which dispatches to the blob path
716/// for blob-shaped ciphers — invoking this directly on a blob cipher would
717/// silently return a `CipherView` with empty fields.
718pub(crate) fn lenient_decrypt_cipher_view(
719    cipher: &Cipher,
720    ctx: &mut KeyStoreContext<KeySlotIds>,
721    key: SymmetricKeySlotId,
722) -> Result<CipherView, CryptoError> {
723    let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &cipher.key)?;
724
725    // Separate successful and failed attachment decryptions
726    let (attachments, attachment_decryption_failures) =
727        attachment::decrypt_attachments_with_failures(
728            cipher.attachments.as_deref().unwrap_or_default(),
729            ctx,
730            ciphers_key,
731        );
732
733    let mut view = CipherView {
734        id: cipher.id,
735        organization_id: cipher.organization_id,
736        folder_id: cipher.folder_id,
737        collection_ids: cipher.collection_ids.clone(),
738        // ⚠️ CONTRACT VIOLATION of `bitwarden_crypto::Decryptable`: the resulting `CipherView` is a
739        // decrypted DTO, yet `key` (the cipher's content key wrapped under the user/org key) is
740        // copied through still encrypted (`cipher.key.clone()`) rather than decrypted, because
741        // `CipherView` stores it as an `EncString`. The wrapped key is therefore key-bound to the
742        // original user/org key: a `CipherView` cannot be re-encrypted under a different user/org
743        // key without explicitly rewrapping `key`.
744        key: cipher.key.clone(),
745        name: cipher
746            .name
747            .as_ref()
748            .and_then(|n| n.decrypt(ctx, ciphers_key).ok())
749            .unwrap_or_default(),
750        notes: cipher.notes.decrypt(ctx, ciphers_key).ok().flatten(),
751        r#type: cipher.r#type,
752        login: cipher.login.decrypt(ctx, ciphers_key).ok().flatten(),
753        identity: cipher.identity.decrypt(ctx, ciphers_key).ok().flatten(),
754        card: cipher.card.decrypt(ctx, ciphers_key).ok().flatten(),
755        secure_note: cipher.secure_note.decrypt(ctx, ciphers_key).ok().flatten(),
756        ssh_key: cipher.ssh_key.decrypt(ctx, ciphers_key).ok().flatten(),
757        bank_account: cipher.bank_account.decrypt(ctx, ciphers_key).ok().flatten(),
758        drivers_license: cipher
759            .drivers_license
760            .decrypt(ctx, ciphers_key)
761            .ok()
762            .flatten(),
763        passport: cipher.passport.decrypt(ctx, ciphers_key).ok().flatten(),
764        favorite: cipher.favorite,
765        reprompt: cipher.reprompt,
766        organization_use_totp: cipher.organization_use_totp,
767        edit: cipher.edit,
768        permissions: cipher.permissions,
769        view_password: cipher.view_password,
770        local_data: cipher.local_data.decrypt(ctx, ciphers_key).ok().flatten(),
771        attachments: Some(attachments),
772        attachment_decryption_failures: Some(attachment_decryption_failures),
773        fields: cipher.fields.decrypt(ctx, ciphers_key).ok().flatten(),
774        password_history: cipher
775            .password_history
776            .decrypt(ctx, ciphers_key)
777            .ok()
778            .flatten(),
779        creation_date: cipher.creation_date,
780        deleted_date: cipher.deleted_date,
781        revision_date: cipher.revision_date,
782        archived_date: cipher.archived_date,
783    };
784
785    // For compatibility we only remove URLs with invalid checksums if the cipher has a key
786    // or the user is on Crypto V2
787    if view.key.is_some()
788        || ctx.get_security_state_version() >= MINIMUM_ENFORCE_ICON_URI_HASH_VERSION
789    {
790        view.remove_invalid_checksums();
791    }
792
793    Ok(view)
794}
795
796impl Cipher {
797    /// Decrypt the individual encryption key for this cipher into the provided [KeyStoreContext]
798    /// and return it's identifier. Note that some ciphers do not have individual encryption
799    /// keys, in which case this will return the provided key identifier instead
800    ///
801    /// # Arguments
802    ///
803    /// * `ctx` - The key store context where the cipher key will be decrypted, if it exists
804    /// * `key` - The key to use to decrypt the cipher key, this should be the user or organization
805    ///   key
806    /// * `ciphers_key` - The encrypted cipher key
807    #[bitwarden_logging::instrument(err)]
808    pub(crate) fn decrypt_cipher_key(
809        ctx: &mut KeyStoreContext<KeySlotIds>,
810        key: SymmetricKeySlotId,
811        ciphers_key: &Option<EncString>,
812    ) -> Result<SymmetricKeySlotId, CryptoError> {
813        match ciphers_key {
814            Some(ciphers_key) => ctx.unwrap_symmetric_key(key, ciphers_key),
815            None => Ok(key),
816        }
817    }
818
819    /// Builds the cryptographic material for a new attachment: a fresh key (raw and wrapped with
820    /// the cipher key) plus the encrypted file name.
821    ///
822    /// # Arguments
823    ///
824    /// * `ctx` - The key store context where the new attachment key will be registered
825    /// * `file_name` - The plaintext file name to encrypt with the cipher key
826    #[bitwarden_logging::instrument(err)]
827    pub(crate) fn make_attachment_material(
828        &self,
829        ctx: &mut KeyStoreContext<KeySlotIds>,
830        file_name: &str,
831    ) -> Result<attachment::AttachmentMaterial, CryptoError> {
832        let cipher_key = Self::decrypt_cipher_key(ctx, self.key_identifier(), &self.key)?;
833        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
834        let slot = ctx.add_local_symmetric_key(key.clone());
835        let wrapped_key = ctx.wrap_symmetric_key(cipher_key, slot)?;
836        let encrypted_file_name = file_name.encrypt(ctx, cipher_key)?;
837        Ok(attachment::AttachmentMaterial {
838            key,
839            wrapped_key,
840            encrypted_file_name,
841        })
842    }
843
844    /// Temporary helper to return a [CipherKind] instance based on the cipher type.
845    fn get_kind(&self) -> Option<&dyn CipherKind> {
846        match self.r#type {
847            CipherType::Login => self.login.as_ref().map(|v| v as _),
848            CipherType::Card => self.card.as_ref().map(|v| v as _),
849            CipherType::Identity => self.identity.as_ref().map(|v| v as _),
850            CipherType::SshKey => self.ssh_key.as_ref().map(|v| v as _),
851            CipherType::SecureNote => self.secure_note.as_ref().map(|v| v as _),
852            CipherType::BankAccount => self.bank_account.as_ref().map(|v| v as _),
853            CipherType::DriversLicense => self.drivers_license.as_ref().map(|v| v as _),
854            CipherType::Passport => self.passport.as_ref().map(|v| v as _),
855        }
856    }
857
858    /// Returns the decrypted subtitle for the cipher, if applicable.
859    fn decrypt_subtitle(
860        &self,
861        ctx: &mut KeyStoreContext<KeySlotIds>,
862        key: SymmetricKeySlotId,
863    ) -> Result<String, CryptoError> {
864        self.get_kind()
865            .map(|sub| sub.decrypt_subtitle(ctx, key))
866            .unwrap_or_else(|| Ok(String::new()))
867    }
868
869    /// Returns a list of copyable field names for this cipher,
870    /// based on the cipher type and populated properties.
871    fn get_copyable_fields(&self) -> Vec<CopyableCipherFields> {
872        self.get_kind()
873            .map(|kind| kind.get_copyable_fields(Some(self)))
874            .unwrap_or_default()
875    }
876
877    /// This replaces the values provided by the API in the `login`, `secure_note`, `card`,
878    /// `identity`, `ssh_key`, `bank_account`, `passport`, and `drivers_license` fields,
879    /// relying instead on client-side parsing of the
880    /// `data` field.
881    #[allow(unused)] // Will be used by future changes to support cipher versioning.
882    pub(crate) fn populate_cipher_types(&mut self) -> Result<(), VaultParseError> {
883        let data = self
884            .data
885            .as_ref()
886            .ok_or(VaultParseError::MissingField(MissingFieldError("data")))?;
887
888        match &self.r#type {
889            crate::CipherType::Login => self.login = serde_json::from_str(data)?,
890            crate::CipherType::SecureNote => self.secure_note = serde_json::from_str(data)?,
891            crate::CipherType::Card => self.card = serde_json::from_str(data)?,
892            crate::CipherType::Identity => self.identity = serde_json::from_str(data)?,
893            crate::CipherType::SshKey => self.ssh_key = serde_json::from_str(data)?,
894            crate::CipherType::BankAccount => self.bank_account = serde_json::from_str(data)?,
895            crate::CipherType::DriversLicense => self.drivers_license = serde_json::from_str(data)?,
896            crate::CipherType::Passport => self.passport = serde_json::from_str(data)?,
897        }
898        Ok(())
899    }
900
901    /// Marks the cipher as soft deleted by setting `deletion_date` to now.
902    pub(crate) fn soft_delete(&mut self) {
903        self.deleted_date = Some(Utc::now());
904    }
905}
906impl CipherView {
907    #[allow(missing_docs)]
908    pub fn generate_cipher_key(
909        &mut self,
910        ctx: &mut KeyStoreContext<KeySlotIds>,
911        wrapping_key: SymmetricKeySlotId,
912    ) -> Result<(), CryptoError> {
913        let old_unwrapping_key = self.key_identifier();
914        let old_ciphers_key = Cipher::decrypt_cipher_key(ctx, old_unwrapping_key, &self.key)?;
915
916        let new_key = ctx.generate_symmetric_key();
917
918        self.reencrypt_attachment_keys(ctx, old_ciphers_key, new_key)?;
919        self.reencrypt_fido2_credentials(ctx, old_ciphers_key, new_key)?;
920
921        self.key = Some(ctx.wrap_symmetric_key(wrapping_key, new_key)?);
922        Ok(())
923    }
924
925    #[allow(missing_docs)]
926    pub fn generate_checksums(&mut self) {
927        if let Some(l) = self.login.as_mut() {
928            l.generate_checksums();
929        }
930    }
931
932    #[allow(missing_docs)]
933    pub fn remove_invalid_checksums(&mut self) {
934        if let Some(uris) = self.login.as_mut().and_then(|l| l.uris.as_mut()) {
935            uris.retain(|u| u.is_checksum_valid());
936        }
937    }
938
939    fn reencrypt_attachment_keys(
940        &mut self,
941        ctx: &mut KeyStoreContext<KeySlotIds>,
942        old_key: SymmetricKeySlotId,
943        new_key: SymmetricKeySlotId,
944    ) -> Result<(), CryptoError> {
945        if let Some(attachments) = &mut self.attachments {
946            AttachmentView::reencrypt_keys(attachments, ctx, old_key, new_key)?;
947        }
948        Ok(())
949    }
950
951    #[allow(missing_docs)]
952    pub fn decrypt_fido2_credentials(
953        &self,
954        ctx: &mut KeyStoreContext<KeySlotIds>,
955    ) -> Result<Vec<Fido2CredentialView>, CryptoError> {
956        let key = self.key_identifier();
957        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
958
959        Ok(self
960            .login
961            .as_ref()
962            .and_then(|l| l.fido2_credentials.as_ref())
963            .map(|f| f.decrypt(ctx, ciphers_key))
964            .transpose()?
965            .unwrap_or_default())
966    }
967
968    fn reencrypt_fido2_credentials(
969        &mut self,
970        ctx: &mut KeyStoreContext<KeySlotIds>,
971        old_key: SymmetricKeySlotId,
972        new_key: SymmetricKeySlotId,
973    ) -> Result<(), CryptoError> {
974        if let Some(login) = self.login.as_mut() {
975            login.reencrypt_fido2_credentials(ctx, old_key, new_key)?;
976        }
977        Ok(())
978    }
979
980    /// Moves the cipher to an organization by re-encrypting the cipher keys with the organization
981    /// key and assigning the organization ID to the cipher.
982    ///
983    /// # Arguments
984    /// * `ctx` - The key store context where the cipher keys will be re-encrypted
985    /// * `organization_id` - The ID of the organization to move the cipher to
986    pub fn move_to_organization(
987        &mut self,
988        ctx: &mut KeyStoreContext<KeySlotIds>,
989        organization_id: OrganizationId,
990    ) -> Result<(), CipherError> {
991        let new_key = SymmetricKeySlotId::Organization(organization_id);
992
993        self.reencrypt_cipher_keys(ctx, new_key)?;
994        self.organization_id = Some(organization_id);
995
996        Ok(())
997    }
998
999    /// Re-encrypt the cipher key(s) using a new wrapping key.
1000    ///
1001    /// If the cipher has a cipher key, it will be re-encrypted with the new wrapping key.
1002    /// Otherwise, the cipher will re-encrypt all attachment keys and FIDO2 credential keys
1003    pub fn reencrypt_cipher_keys(
1004        &mut self,
1005        ctx: &mut KeyStoreContext<KeySlotIds>,
1006        new_wrapping_key: SymmetricKeySlotId,
1007    ) -> Result<(), CipherError> {
1008        let old_key = self.key_identifier();
1009
1010        // If any attachment is missing a key we can't reencrypt the attachment keys
1011        if self.attachments.iter().flatten().any(|a| a.key.is_none()) {
1012            return Err(CipherError::AttachmentsWithoutKeys);
1013        }
1014
1015        // If the cipher has a key, reencrypt it with the new wrapping key
1016        if self.key.is_some() {
1017            // Decrypt the current cipher key using the existing wrapping key
1018            let cipher_key = Cipher::decrypt_cipher_key(ctx, old_key, &self.key)?;
1019
1020            // Wrap the cipher key with the new wrapping key
1021            self.key = Some(ctx.wrap_symmetric_key(new_wrapping_key, cipher_key)?);
1022        } else {
1023            // The cipher does not have a key, we must reencrypt all attachment keys and FIDO2
1024            // credentials individually
1025            self.reencrypt_attachment_keys(ctx, old_key, new_wrapping_key)?;
1026            self.reencrypt_fido2_credentials(ctx, old_key, new_wrapping_key)?;
1027        }
1028
1029        Ok(())
1030    }
1031
1032    #[allow(missing_docs)]
1033    pub fn set_new_fido2_credentials(
1034        &mut self,
1035        ctx: &mut KeyStoreContext<KeySlotIds>,
1036        creds: Vec<Fido2CredentialFullView>,
1037    ) -> Result<(), CipherError> {
1038        let key = self.key_identifier();
1039
1040        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
1041
1042        require!(self.login.as_mut()).fido2_credentials =
1043            Some(creds.encrypt_composite(ctx, ciphers_key)?);
1044
1045        Ok(())
1046    }
1047
1048    #[allow(missing_docs)]
1049    pub fn get_fido2_credentials(
1050        &self,
1051        ctx: &mut KeyStoreContext<KeySlotIds>,
1052    ) -> Result<Vec<Fido2CredentialFullView>, CipherError> {
1053        let key = self.key_identifier();
1054
1055        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
1056
1057        let login = require!(self.login.as_ref());
1058        let creds = require!(login.fido2_credentials.as_ref());
1059        let res = creds.decrypt(ctx, ciphers_key)?;
1060        Ok(res)
1061    }
1062
1063    #[allow(missing_docs)]
1064    pub fn decrypt_fido2_private_key(
1065        &self,
1066        ctx: &mut KeyStoreContext<KeySlotIds>,
1067    ) -> Result<String, CipherError> {
1068        let fido2_credential = self.get_fido2_credentials(ctx)?;
1069
1070        Ok(fido2_credential[0].key_value.clone())
1071    }
1072
1073    pub(crate) fn update_password_history(&mut self, original_cipher: &CipherView) {
1074        let changes = self
1075            .login
1076            .as_mut()
1077            .map_or(vec![], |login| {
1078                login.detect_password_change(&original_cipher.login)
1079            })
1080            .into_iter()
1081            .chain(self.fields.as_deref().map_or(vec![], |fields| {
1082                FieldView::detect_hidden_field_changes(
1083                    fields,
1084                    original_cipher.fields.as_deref().unwrap_or(&[]),
1085                )
1086            }))
1087            .rev()
1088            .chain(original_cipher.password_history.iter().flatten().cloned())
1089            .take(MAX_PASSWORD_HISTORY_ENTRIES)
1090            .collect();
1091        self.password_history = Some(changes)
1092    }
1093
1094    /// Projects this [`CipherView`] into a [`CipherListView`].
1095    ///
1096    /// Used by the blob decryption path: blob ciphers are fully unsealed to a
1097    /// `CipherView` by [`decrypt_blob_cipher`], and this method then derives the
1098    /// list-view shape without re-decrypting any sensitive fields.
1099    ///
1100    /// The login `totp` is re-encrypted under the cipher key because
1101    /// [`LoginListView::totp`] stores an [`EncString`] (decrypted lazily via
1102    /// [`CipherListView::get_totp_key`]); avoids a breaking change by keeping the
1103    /// existing API contract
1104    pub(crate) fn to_list_view(
1105        &self,
1106        ctx: &mut KeyStoreContext<KeySlotIds>,
1107        key: SymmetricKeySlotId,
1108    ) -> Result<CipherListView, CryptoError> {
1109        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
1110
1111        let all_attachments = || {
1112            self.attachments
1113                .iter()
1114                .flatten()
1115                .chain(self.attachment_decryption_failures.iter().flatten())
1116        };
1117        let attachments_count = all_attachments().count() as u32;
1118        let has_old_attachments = all_attachments().any(|att| att.key.is_none());
1119
1120        let list_type = match self.r#type {
1121            CipherType::Login => {
1122                let login = self
1123                    .login
1124                    .as_ref()
1125                    .ok_or(CryptoError::MissingField("login"))?;
1126                CipherListViewType::Login(login.to_list_view(ctx, ciphers_key)?)
1127            }
1128            CipherType::SecureNote => CipherListViewType::SecureNote,
1129            CipherType::Card => {
1130                let card = self
1131                    .card
1132                    .as_ref()
1133                    .ok_or(CryptoError::MissingField("card"))?;
1134                CipherListViewType::Card(CardListView {
1135                    brand: card.brand.clone(),
1136                })
1137            }
1138            CipherType::Identity => CipherListViewType::Identity,
1139            CipherType::SshKey => CipherListViewType::SshKey,
1140            CipherType::BankAccount => {
1141                let bank_account = self
1142                    .bank_account
1143                    .as_ref()
1144                    .ok_or(CryptoError::MissingField("bank_account"))?;
1145                CipherListViewType::BankAccount(BankAccountListView {
1146                    account_number: bank_account.account_number.clone(),
1147                    account_type: bank_account.account_type.clone(),
1148                })
1149            }
1150            CipherType::DriversLicense => CipherListViewType::DriversLicense,
1151            CipherType::Passport => CipherListViewType::Passport,
1152        };
1153
1154        Ok(CipherListView {
1155            id: self.id,
1156            organization_id: self.organization_id,
1157            folder_id: self.folder_id,
1158            collection_ids: self.collection_ids.clone(),
1159            key: self.key.clone(),
1160            name: self.name.clone(),
1161            subtitle: self.subtitle(),
1162            r#type: list_type,
1163            favorite: self.favorite,
1164            reprompt: self.reprompt,
1165            organization_use_totp: self.organization_use_totp,
1166            edit: self.edit,
1167            permissions: self.permissions,
1168            view_password: self.view_password,
1169            attachments: attachments_count,
1170            has_old_attachments,
1171            creation_date: self.creation_date,
1172            deleted_date: self.deleted_date,
1173            revision_date: self.revision_date,
1174            archived_date: self.archived_date,
1175            copyable_fields: self.get_copyable_fields(),
1176            local_data: self.local_data.clone(),
1177            #[cfg(feature = "wasm")]
1178            notes: self.notes.clone(),
1179            #[cfg(feature = "wasm")]
1180            fields: self.fields.as_ref().map(|fields| {
1181                fields
1182                    .iter()
1183                    .cloned()
1184                    .map(field::FieldListView::from)
1185                    .collect()
1186            }),
1187            #[cfg(feature = "wasm")]
1188            attachment_names: self.attachments.as_ref().map(|attachments| {
1189                attachments
1190                    .iter()
1191                    .filter_map(|a| a.file_name.clone())
1192                    .collect()
1193            }),
1194        })
1195    }
1196
1197    /// Derives the list-view subtitle from the decrypted view fields.
1198    ///
1199    /// Mirrors the per-type logic that [`CipherKind::decrypt_subtitle`] runs against
1200    /// encrypted fields, but operates on the already-decrypted view.
1201    fn subtitle(&self) -> String {
1202        match self.r#type {
1203            CipherType::Login => self
1204                .login
1205                .as_ref()
1206                .and_then(|l| l.username.clone())
1207                .unwrap_or_default(),
1208            CipherType::Card => self
1209                .card
1210                .as_ref()
1211                .map(|c| card::build_subtitle_card(c.brand.clone(), c.number.clone()))
1212                .unwrap_or_default(),
1213            CipherType::Identity => self
1214                .identity
1215                .as_ref()
1216                .map(|i| {
1217                    identity::build_subtitle_identity(i.first_name.clone(), i.last_name.clone())
1218                })
1219                .unwrap_or_default(),
1220            CipherType::SshKey => self
1221                .ssh_key
1222                .as_ref()
1223                .map(|s| s.fingerprint.clone())
1224                .unwrap_or_default(),
1225            CipherType::SecureNote => String::new(),
1226            CipherType::BankAccount => self
1227                .bank_account
1228                .as_ref()
1229                .map(|b| b.bank_name.clone().unwrap_or_default())
1230                .unwrap_or_default(),
1231            CipherType::DriversLicense => self
1232                .drivers_license
1233                .as_ref()
1234                .map(|d| {
1235                    drivers_license::build_subtitle_drivers_license(
1236                        d.first_name.clone(),
1237                        d.last_name.clone(),
1238                        d.issuing_state.clone(),
1239                    )
1240                })
1241                .unwrap_or_default(),
1242            CipherType::Passport => self
1243                .passport
1244                .as_ref()
1245                .map(|p| {
1246                    passport::build_subtitle_passport(
1247                        p.given_name.clone(),
1248                        p.surname.clone(),
1249                        p.issuing_country.clone(),
1250                    )
1251                })
1252                .unwrap_or_default(),
1253        }
1254    }
1255
1256    /// Derives copyable-field hints from the decrypted view fields.
1257    ///
1258    /// Mirrors the per-type logic that [`CipherKind::get_copyable_fields`] runs on
1259    /// encrypted types.
1260    fn get_copyable_fields(&self) -> Vec<CopyableCipherFields> {
1261        match self.r#type {
1262            CipherType::Login => self
1263                .login
1264                .as_ref()
1265                .map(|l| {
1266                    [
1267                        l.username
1268                            .as_ref()
1269                            .map(|_| CopyableCipherFields::LoginUsername),
1270                        l.password
1271                            .as_ref()
1272                            .map(|_| CopyableCipherFields::LoginPassword),
1273                        l.totp.as_ref().map(|_| CopyableCipherFields::LoginTotp),
1274                    ]
1275                    .into_iter()
1276                    .flatten()
1277                    .collect()
1278                })
1279                .unwrap_or_default(),
1280            CipherType::Card => self
1281                .card
1282                .as_ref()
1283                .map(|c| {
1284                    [
1285                        c.number.as_ref().map(|_| CopyableCipherFields::CardNumber),
1286                        c.code
1287                            .as_ref()
1288                            .map(|_| CopyableCipherFields::CardSecurityCode),
1289                    ]
1290                    .into_iter()
1291                    .flatten()
1292                    .collect()
1293                })
1294                .unwrap_or_default(),
1295            CipherType::Identity => self
1296                .identity
1297                .as_ref()
1298                .map(|i| {
1299                    [
1300                        i.username
1301                            .as_ref()
1302                            .map(|_| CopyableCipherFields::IdentityUsername),
1303                        i.email
1304                            .as_ref()
1305                            .map(|_| CopyableCipherFields::IdentityEmail),
1306                        i.phone
1307                            .as_ref()
1308                            .map(|_| CopyableCipherFields::IdentityPhone),
1309                        i.address1
1310                            .as_ref()
1311                            .or(i.address2.as_ref())
1312                            .or(i.address3.as_ref())
1313                            .or(i.city.as_ref())
1314                            .or(i.state.as_ref())
1315                            .or(i.postal_code.as_ref())
1316                            .map(|_| CopyableCipherFields::IdentityAddress),
1317                    ]
1318                    .into_iter()
1319                    .flatten()
1320                    .collect()
1321                })
1322                .unwrap_or_default(),
1323            CipherType::SshKey => vec![CopyableCipherFields::SshKey],
1324            CipherType::SecureNote => self
1325                .notes
1326                .as_ref()
1327                .map(|_| vec![CopyableCipherFields::SecureNotes])
1328                .unwrap_or_default(),
1329            CipherType::BankAccount => self
1330                .bank_account
1331                .as_ref()
1332                .map(|b| {
1333                    [
1334                        b.name_on_account
1335                            .as_ref()
1336                            .map(|_| CopyableCipherFields::BankAccountNameOnAccount),
1337                        b.account_number
1338                            .as_ref()
1339                            .map(|_| CopyableCipherFields::BankAccountAccountNumber),
1340                        b.routing_number
1341                            .as_ref()
1342                            .map(|_| CopyableCipherFields::BankAccountRoutingNumber),
1343                        b.branch_number
1344                            .as_ref()
1345                            .map(|_| CopyableCipherFields::BankAccountBranchNumber),
1346                        b.pin.as_ref().map(|_| CopyableCipherFields::BankAccountPin),
1347                        b.iban
1348                            .as_ref()
1349                            .map(|_| CopyableCipherFields::BankAccountIban),
1350                        b.swift_code
1351                            .as_ref()
1352                            .map(|_| CopyableCipherFields::BankAccountSwift),
1353                    ]
1354                    .into_iter()
1355                    .flatten()
1356                    .collect()
1357                })
1358                .unwrap_or_default(),
1359            CipherType::DriversLicense => self
1360                .drivers_license
1361                .as_ref()
1362                .map(|d| {
1363                    [
1364                        d.first_name
1365                            .as_ref()
1366                            .map(|_| CopyableCipherFields::DriversLicenseFirstName),
1367                        d.middle_name
1368                            .as_ref()
1369                            .map(|_| CopyableCipherFields::DriversLicenseMiddleName),
1370                        d.last_name
1371                            .as_ref()
1372                            .map(|_| CopyableCipherFields::DriversLicenseLastName),
1373                        d.license_number
1374                            .as_ref()
1375                            .map(|_| CopyableCipherFields::DriversLicenseLicenseNumber),
1376                    ]
1377                    .into_iter()
1378                    .flatten()
1379                    .collect()
1380                })
1381                .unwrap_or_default(),
1382            CipherType::Passport => self
1383                .passport
1384                .as_ref()
1385                .map(|p| {
1386                    [
1387                        p.given_name
1388                            .as_ref()
1389                            .map(|_| CopyableCipherFields::PassportGivenName),
1390                        p.surname
1391                            .as_ref()
1392                            .map(|_| CopyableCipherFields::PassportSurname),
1393                        p.passport_number
1394                            .as_ref()
1395                            .map(|_| CopyableCipherFields::PassportPassportNumber),
1396                        p.national_identification_number
1397                            .as_ref()
1398                            .map(|_| CopyableCipherFields::PassportNationalIdentificationNumber),
1399                    ]
1400                    .into_iter()
1401                    .flatten()
1402                    .collect()
1403                })
1404                .unwrap_or_default(),
1405        }
1406    }
1407}
1408
1409/// Lenient `Cipher` → `CipherListView` decryption body. Used by the default
1410/// [`Decryptable`] impl on `Cipher`; see [`lenient_decrypt_cipher_view`] for rationale.
1411pub(crate) fn lenient_decrypt_cipher_list_view(
1412    cipher: &Cipher,
1413    ctx: &mut KeyStoreContext<KeySlotIds>,
1414    key: SymmetricKeySlotId,
1415) -> Result<CipherListView, CryptoError> {
1416    let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &cipher.key)?;
1417
1418    Ok(CipherListView {
1419        id: cipher.id,
1420        organization_id: cipher.organization_id,
1421        folder_id: cipher.folder_id,
1422        collection_ids: cipher.collection_ids.clone(),
1423        // ⚠️ pass-through of the wrapped, key-bound cipher key — see the contract-violation note in
1424        // `lenient_decrypt_cipher_view`.
1425        key: cipher.key.clone(),
1426        name: cipher
1427            .name
1428            .as_ref()
1429            .and_then(|n| n.decrypt(ctx, ciphers_key).ok())
1430            .unwrap_or_default(),
1431        subtitle: cipher
1432            .decrypt_subtitle(ctx, ciphers_key)
1433            .ok()
1434            .unwrap_or_default(),
1435        r#type: match cipher.r#type {
1436            CipherType::Login => {
1437                let login = cipher
1438                    .login
1439                    .as_ref()
1440                    .ok_or(CryptoError::MissingField("login"))?;
1441                CipherListViewType::Login(login.decrypt(ctx, ciphers_key)?)
1442            }
1443            CipherType::SecureNote => CipherListViewType::SecureNote,
1444            CipherType::Card => {
1445                let card = cipher
1446                    .card
1447                    .as_ref()
1448                    .ok_or(CryptoError::MissingField("card"))?;
1449                CipherListViewType::Card(card.decrypt(ctx, ciphers_key)?)
1450            }
1451            CipherType::Identity => CipherListViewType::Identity,
1452            CipherType::SshKey => CipherListViewType::SshKey,
1453            CipherType::BankAccount => {
1454                let bank_account = cipher
1455                    .bank_account
1456                    .as_ref()
1457                    .ok_or(CryptoError::MissingField("bank_account"))?;
1458                CipherListViewType::BankAccount(bank_account.decrypt(ctx, ciphers_key)?)
1459            }
1460            CipherType::Passport => CipherListViewType::Passport,
1461            CipherType::DriversLicense => CipherListViewType::DriversLicense,
1462        },
1463        favorite: cipher.favorite,
1464        reprompt: cipher.reprompt,
1465        organization_use_totp: cipher.organization_use_totp,
1466        edit: cipher.edit,
1467        permissions: cipher.permissions,
1468        view_password: cipher.view_password,
1469        attachments: cipher
1470            .attachments
1471            .as_ref()
1472            .map(|a| a.len() as u32)
1473            .unwrap_or(0),
1474        has_old_attachments: cipher
1475            .attachments
1476            .as_ref()
1477            .map(|a| a.iter().any(|att| att.key.is_none()))
1478            .unwrap_or(false),
1479        creation_date: cipher.creation_date,
1480        deleted_date: cipher.deleted_date,
1481        revision_date: cipher.revision_date,
1482        copyable_fields: cipher.get_copyable_fields(),
1483        local_data: cipher.local_data.decrypt(ctx, ciphers_key)?,
1484        archived_date: cipher.archived_date,
1485        #[cfg(feature = "wasm")]
1486        notes: cipher.notes.decrypt(ctx, ciphers_key).ok().flatten(),
1487        #[cfg(feature = "wasm")]
1488        fields: cipher.fields.as_ref().map(|fields| {
1489            fields
1490                .iter()
1491                .filter_map(|f| {
1492                    f.decrypt(ctx, ciphers_key)
1493                        .ok()
1494                        .map(field::FieldListView::from)
1495                })
1496                .collect()
1497        }),
1498        #[cfg(feature = "wasm")]
1499        attachment_names: cipher.attachments.as_ref().map(|attachments| {
1500            attachments
1501                .iter()
1502                .filter_map(|a| a.file_name.decrypt(ctx, ciphers_key).ok().flatten())
1503                .collect()
1504        }),
1505    })
1506}
1507
1508impl IdentifyKey<SymmetricKeySlotId> for Cipher {
1509    fn key_identifier(&self) -> SymmetricKeySlotId {
1510        match self.organization_id {
1511            Some(organization_id) => SymmetricKeySlotId::Organization(organization_id),
1512            None => SymmetricKeySlotId::User,
1513        }
1514    }
1515}
1516
1517impl Decryptable<KeySlotIds, SymmetricKeySlotId, CipherView> for Cipher {
1518    #[bitwarden_logging::instrument(err, fields(cipher_id = ?self.id, org_id = ?self.organization_id, kind = ?self.r#type))]
1519    fn decrypt(
1520        &self,
1521        ctx: &mut KeyStoreContext<KeySlotIds>,
1522        key: SymmetricKeySlotId,
1523    ) -> Result<CipherView, CryptoError> {
1524        match try_parse_blob(self) {
1525            Some(sealed) => decrypt_blob_cipher(self, &sealed, ctx, key).map_err(CryptoError::from),
1526            None => lenient_decrypt_cipher_view(self, ctx, key),
1527        }
1528    }
1529}
1530
1531impl Decryptable<KeySlotIds, SymmetricKeySlotId, CipherListView> for Cipher {
1532    fn decrypt(
1533        &self,
1534        ctx: &mut KeyStoreContext<KeySlotIds>,
1535        key: SymmetricKeySlotId,
1536    ) -> Result<CipherListView, CryptoError> {
1537        match try_parse_blob(self) {
1538            Some(sealed) => decrypt_blob_cipher(self, &sealed, ctx, key)?.to_list_view(ctx, key),
1539            None => lenient_decrypt_cipher_list_view(self, ctx, key),
1540        }
1541    }
1542}
1543
1544impl IdentifyKey<SymmetricKeySlotId> for CipherView {
1545    fn key_identifier(&self) -> SymmetricKeySlotId {
1546        match self.organization_id {
1547            Some(organization_id) => SymmetricKeySlotId::Organization(organization_id),
1548            None => SymmetricKeySlotId::User,
1549        }
1550    }
1551}
1552
1553impl IdentifyKey<SymmetricKeySlotId> for CipherListView {
1554    fn key_identifier(&self) -> SymmetricKeySlotId {
1555        match self.organization_id {
1556            Some(organization_id) => SymmetricKeySlotId::Organization(organization_id),
1557            None => SymmetricKeySlotId::User,
1558        }
1559    }
1560}
1561
1562/// Generic wrapper that uses strict decryption: field decryption errors are propagated
1563/// instead of silently nulling out the affected fields.
1564///
1565/// This is a transitional type gated behind the `PM-34500-strict_cipher_decryption` feature flag.
1566/// It will eventually replace the default lenient [Decryptable] implementations.
1567///
1568/// TODO [PM-34531]: Remove StrictDecrypt and `PM-34500-strict_cipher_decryption` feature flag
1569/// after feature has fully rolled out.
1570pub(crate) struct StrictDecrypt<T>(pub(crate) T);
1571
1572impl IdentifyKey<SymmetricKeySlotId> for StrictDecrypt<Cipher> {
1573    fn key_identifier(&self) -> SymmetricKeySlotId {
1574        self.0.key_identifier()
1575    }
1576}
1577
1578impl Decryptable<KeySlotIds, SymmetricKeySlotId, CipherView> for StrictDecrypt<Cipher> {
1579    #[bitwarden_logging::instrument(err, fields(cipher_id = ?self.0.id, org_id = ?self.0.organization_id, kind = ?self.0.r#type))]
1580    fn decrypt(
1581        &self,
1582        ctx: &mut KeyStoreContext<KeySlotIds>,
1583        key: SymmetricKeySlotId,
1584    ) -> Result<CipherView, CryptoError> {
1585        match try_parse_blob(&self.0) {
1586            Some(sealed) => {
1587                decrypt_blob_cipher(&self.0, &sealed, ctx, key).map_err(CryptoError::from)
1588            }
1589            None => strict_decrypt_cipher_view(&self.0, ctx, key),
1590        }
1591    }
1592}
1593
1594/// Strict Cipher → CipherView decryption body, used by the `StrictDecrypt<Cipher>` impl
1595/// when the cipher is in the legacy field-level format.
1596fn strict_decrypt_cipher_view(
1597    cipher: &Cipher,
1598    ctx: &mut KeyStoreContext<KeySlotIds>,
1599    key: SymmetricKeySlotId,
1600) -> Result<CipherView, CryptoError> {
1601    let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &cipher.key)?;
1602
1603    // Separate successful and failed attachment decryptions
1604    let (attachments, attachment_decryption_failures) =
1605        attachment::decrypt_attachments_with_failures(
1606            cipher.attachments.as_deref().unwrap_or_default(),
1607            ctx,
1608            ciphers_key,
1609        );
1610
1611    let mut view = CipherView {
1612        id: cipher.id,
1613        organization_id: cipher.organization_id,
1614        folder_id: cipher.folder_id,
1615        collection_ids: cipher.collection_ids.clone(),
1616        // ⚠️ pass-through of the wrapped, key-bound cipher key — see the contract-violation note in
1617        // `lenient_decrypt_cipher_view`.
1618        key: cipher.key.clone(),
1619        name: cipher
1620            .name
1621            .as_ref()
1622            .ok_or(CryptoError::MissingField("name"))?
1623            .decrypt(ctx, ciphers_key)?,
1624        notes: cipher.notes.decrypt(ctx, ciphers_key)?,
1625        r#type: cipher.r#type,
1626        login: cipher
1627            .login
1628            .as_ref()
1629            .map(|l| StrictDecrypt(l).decrypt(ctx, ciphers_key))
1630            .transpose()?,
1631        identity: cipher
1632            .identity
1633            .as_ref()
1634            .map(|i| StrictDecrypt(i).decrypt(ctx, ciphers_key))
1635            .transpose()?,
1636        card: cipher
1637            .card
1638            .as_ref()
1639            .map(|c| StrictDecrypt(c).decrypt(ctx, ciphers_key))
1640            .transpose()?,
1641        secure_note: cipher.secure_note.decrypt(ctx, ciphers_key)?,
1642        ssh_key: cipher.ssh_key.decrypt(ctx, ciphers_key)?,
1643        bank_account: cipher.bank_account.decrypt(ctx, ciphers_key)?,
1644        drivers_license: cipher.drivers_license.decrypt(ctx, ciphers_key)?,
1645        passport: cipher.passport.decrypt(ctx, ciphers_key)?,
1646        favorite: cipher.favorite,
1647        reprompt: cipher.reprompt,
1648        organization_use_totp: cipher.organization_use_totp,
1649        edit: cipher.edit,
1650        permissions: cipher.permissions,
1651        view_password: cipher.view_password,
1652        local_data: cipher.local_data.decrypt(ctx, ciphers_key)?,
1653        attachments: Some(attachments),
1654        attachment_decryption_failures: Some(attachment_decryption_failures),
1655        fields: cipher
1656            .fields
1657            .as_ref()
1658            .map(|fields| {
1659                fields
1660                    .iter()
1661                    .map(|f| StrictDecrypt(f).decrypt(ctx, ciphers_key))
1662                    .collect::<Result<Vec<_>, _>>()
1663            })
1664            .transpose()?,
1665        password_history: cipher.password_history.decrypt(ctx, ciphers_key)?,
1666        creation_date: cipher.creation_date,
1667        deleted_date: cipher.deleted_date,
1668        revision_date: cipher.revision_date,
1669        archived_date: cipher.archived_date,
1670    };
1671
1672    // For compatibility we only remove URLs with invalid checksums if the cipher has a key
1673    // or the user is on Crypto V2
1674    if view.key.is_some()
1675        || ctx.get_security_state_version() >= MINIMUM_ENFORCE_ICON_URI_HASH_VERSION
1676    {
1677        view.remove_invalid_checksums();
1678    }
1679
1680    Ok(view)
1681}
1682
1683impl Decryptable<KeySlotIds, SymmetricKeySlotId, CipherListView> for StrictDecrypt<Cipher> {
1684    fn decrypt(
1685        &self,
1686        ctx: &mut KeyStoreContext<KeySlotIds>,
1687        key: SymmetricKeySlotId,
1688    ) -> Result<CipherListView, CryptoError> {
1689        match try_parse_blob(&self.0) {
1690            Some(sealed) => decrypt_blob_cipher(&self.0, &sealed, ctx, key)?.to_list_view(ctx, key),
1691            None => strict_decrypt_cipher_list_view(&self.0, ctx, key),
1692        }
1693    }
1694}
1695
1696/// Strict Cipher → CipherListView decryption body, used by the `StrictDecrypt<Cipher>`
1697/// impl when the cipher is in the legacy field-level format.
1698fn strict_decrypt_cipher_list_view(
1699    cipher: &Cipher,
1700    ctx: &mut KeyStoreContext<KeySlotIds>,
1701    key: SymmetricKeySlotId,
1702) -> Result<CipherListView, CryptoError> {
1703    let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &cipher.key)?;
1704
1705    Ok(CipherListView {
1706        id: cipher.id,
1707        organization_id: cipher.organization_id,
1708        folder_id: cipher.folder_id,
1709        collection_ids: cipher.collection_ids.clone(),
1710        // ⚠️ pass-through of the wrapped, key-bound cipher key — see the contract-violation note in
1711        // `lenient_decrypt_cipher_view`.
1712        key: cipher.key.clone(),
1713        name: cipher
1714            .name
1715            .as_ref()
1716            .ok_or(CryptoError::MissingField("name"))?
1717            .decrypt(ctx, ciphers_key)?,
1718        subtitle: cipher.decrypt_subtitle(ctx, ciphers_key)?,
1719        r#type: match cipher.r#type {
1720            CipherType::Login => {
1721                let login = cipher
1722                    .login
1723                    .as_ref()
1724                    .ok_or(CryptoError::MissingField("login"))?;
1725                CipherListViewType::Login(StrictDecrypt(login).decrypt(ctx, ciphers_key)?)
1726            }
1727            CipherType::SecureNote => CipherListViewType::SecureNote,
1728            CipherType::Card => {
1729                let card = cipher
1730                    .card
1731                    .as_ref()
1732                    .ok_or(CryptoError::MissingField("card"))?;
1733                CipherListViewType::Card(StrictDecrypt(card).decrypt(ctx, ciphers_key)?)
1734            }
1735            CipherType::Identity => CipherListViewType::Identity,
1736            CipherType::SshKey => CipherListViewType::SshKey,
1737            CipherType::BankAccount => {
1738                let bank_account = cipher
1739                    .bank_account
1740                    .as_ref()
1741                    .ok_or(CryptoError::MissingField("bank_account"))?;
1742                CipherListViewType::BankAccount(
1743                    StrictDecrypt(bank_account).decrypt(ctx, ciphers_key)?,
1744                )
1745            }
1746            CipherType::Passport => CipherListViewType::Passport,
1747            CipherType::DriversLicense => CipherListViewType::DriversLicense,
1748        },
1749        favorite: cipher.favorite,
1750        reprompt: cipher.reprompt,
1751        organization_use_totp: cipher.organization_use_totp,
1752        edit: cipher.edit,
1753        permissions: cipher.permissions,
1754        view_password: cipher.view_password,
1755        attachments: cipher
1756            .attachments
1757            .as_ref()
1758            .map(|a| a.len() as u32)
1759            .unwrap_or(0),
1760        has_old_attachments: cipher
1761            .attachments
1762            .as_ref()
1763            .map(|a| a.iter().any(|att| att.key.is_none()))
1764            .unwrap_or(false),
1765        creation_date: cipher.creation_date,
1766        deleted_date: cipher.deleted_date,
1767        revision_date: cipher.revision_date,
1768        copyable_fields: cipher.get_copyable_fields(),
1769        local_data: cipher.local_data.decrypt(ctx, ciphers_key)?,
1770        archived_date: cipher.archived_date,
1771        #[cfg(feature = "wasm")]
1772        notes: cipher.notes.decrypt(ctx, ciphers_key)?,
1773        #[cfg(feature = "wasm")]
1774        fields: cipher
1775            .fields
1776            .as_ref()
1777            .map(|fields| {
1778                fields
1779                    .iter()
1780                    .map(|f| {
1781                        StrictDecrypt(f)
1782                            .decrypt(ctx, ciphers_key)
1783                            .map(field::FieldListView::from)
1784                    })
1785                    .collect::<Result<Vec<_>, _>>()
1786            })
1787            .transpose()?,
1788        #[cfg(feature = "wasm")]
1789        attachment_names: cipher
1790            .attachments
1791            .as_ref()
1792            .map(|attachments| {
1793                attachments
1794                    .iter()
1795                    .map(|a| a.file_name.decrypt(ctx, ciphers_key))
1796                    .collect::<Result<Vec<_>, _>>()
1797            })
1798            .transpose()?
1799            .map(|names| names.into_iter().flatten().collect()),
1800    })
1801}
1802
1803/// Selects between blob and legacy encryption paths. The variant is chosen at
1804/// the [`CiphersClient`] layer via `should_use_blob_encryption`.
1805///
1806/// [`CiphersClient`]: crate::cipher::cipher_client::CiphersClient
1807pub enum EncryptMode<T> {
1808    /// Encrypt as a sealed blob (current format).
1809    Blob(T),
1810    /// Encrypt using the legacy field-level format.
1811    Legacy(T),
1812}
1813
1814impl<T> EncryptMode<T> {
1815    pub(crate) fn inner(&self) -> &T {
1816        match self {
1817            Self::Blob(t) | Self::Legacy(t) => t,
1818        }
1819    }
1820}
1821
1822impl<T> IdentifyKey<SymmetricKeySlotId> for EncryptMode<T>
1823where
1824    T: IdentifyKey<SymmetricKeySlotId>,
1825{
1826    fn key_identifier(&self) -> SymmetricKeySlotId {
1827        self.inner().key_identifier()
1828    }
1829}
1830
1831impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Cipher> for EncryptMode<CipherView> {
1832    fn encrypt_composite(
1833        &self,
1834        ctx: &mut KeyStoreContext<KeySlotIds>,
1835        key: SymmetricKeySlotId,
1836    ) -> Result<Cipher, CryptoError> {
1837        match self {
1838            Self::Blob(view) => {
1839                // `encrypt_blob_cipher_with_wrapping_key` takes `&mut CipherView` because it may
1840                // generate a cipher key; so we operate on a local clone. The explicit `key` is
1841                // respected here so callers can target a non-`User`/`Organization` slot (e.g.
1842                // a `Local` slot during key rotation).
1843                let mut owned = view.clone();
1844                encrypt_blob_cipher_with_wrapping_key(&mut owned, ctx, key)
1845                    .map_err(CryptoError::from)
1846            }
1847            Self::Legacy(view) => view.encrypt_legacy_field_encryption(ctx, key),
1848        }
1849    }
1850}
1851
1852impl TryFrom<CipherDetailsResponseModel> for Cipher {
1853    type Error = VaultParseError;
1854
1855    fn try_from(cipher: CipherDetailsResponseModel) -> Result<Self, Self::Error> {
1856        Ok(Self {
1857            id: cipher.id.map(CipherId::new),
1858            organization_id: cipher.organization_id.map(OrganizationId::new),
1859            folder_id: cipher.folder_id.map(FolderId::new),
1860            collection_ids: cipher
1861                .collection_ids
1862                .unwrap_or_default()
1863                .into_iter()
1864                .map(CollectionId::new)
1865                .collect(),
1866            name: EncString::try_from_optional(cipher.name)?,
1867            notes: EncString::try_from_optional(cipher.notes)?,
1868            r#type: require!(cipher.r#type).try_into()?,
1869            login: cipher.login.map(|l| (*l).try_into()).transpose()?,
1870            identity: cipher.identity.map(|i| (*i).try_into()).transpose()?,
1871            card: cipher.card.map(|c| (*c).try_into()).transpose()?,
1872            secure_note: cipher.secure_note.map(|s| (*s).try_into()).transpose()?,
1873            ssh_key: cipher.ssh_key.map(|s| (*s).try_into()).transpose()?,
1874            bank_account: cipher.bank_account.map(|b| (*b).try_into()).transpose()?,
1875            drivers_license: cipher
1876                .drivers_license
1877                .map(|d| (*d).try_into())
1878                .transpose()?,
1879            passport: cipher.passport.map(|p| (*p).try_into()).transpose()?,
1880            favorite: cipher.favorite.unwrap_or(false),
1881            reprompt: cipher
1882                .reprompt
1883                .map(|r| r.try_into())
1884                .transpose()?
1885                .unwrap_or(CipherRepromptType::None),
1886            organization_use_totp: cipher.organization_use_totp.unwrap_or(true),
1887            edit: cipher.edit.unwrap_or(true),
1888            permissions: cipher.permissions.map(|p| (*p).try_into()).transpose()?,
1889            view_password: cipher.view_password.unwrap_or(true),
1890            local_data: None, // Not sent from server
1891            attachments: cipher
1892                .attachments
1893                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
1894                .transpose()?,
1895            fields: cipher
1896                .fields
1897                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
1898                .transpose()?,
1899            password_history: cipher
1900                .password_history
1901                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
1902                .transpose()?,
1903            creation_date: require!(cipher.creation_date).parse()?,
1904            deleted_date: cipher.deleted_date.map(|d| d.parse()).transpose()?,
1905            revision_date: require!(cipher.revision_date).parse()?,
1906            key: EncString::try_from_optional(cipher.key)?,
1907            archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?,
1908            data: cipher.data,
1909        })
1910    }
1911}
1912
1913impl PartialCipher for CipherDetailsResponseModel {
1914    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
1915        Ok(Cipher {
1916            local_data: cipher.and_then(|c| c.local_data),
1917            ..self.try_into()?
1918        })
1919    }
1920}
1921
1922impl TryFrom<bitwarden_api_api::models::CipherType> for CipherType {
1923    type Error = MissingFieldError;
1924
1925    fn try_from(t: bitwarden_api_api::models::CipherType) -> Result<Self, Self::Error> {
1926        Ok(match t {
1927            bitwarden_api_api::models::CipherType::Login => CipherType::Login,
1928            bitwarden_api_api::models::CipherType::SecureNote => CipherType::SecureNote,
1929            bitwarden_api_api::models::CipherType::Card => CipherType::Card,
1930            bitwarden_api_api::models::CipherType::Identity => CipherType::Identity,
1931            bitwarden_api_api::models::CipherType::SSHKey => CipherType::SshKey,
1932            bitwarden_api_api::models::CipherType::BankAccount => CipherType::BankAccount,
1933            bitwarden_api_api::models::CipherType::Passport => CipherType::Passport,
1934            bitwarden_api_api::models::CipherType::DriversLicense => CipherType::DriversLicense,
1935            bitwarden_api_api::models::CipherType::__Unknown(_) => {
1936                return Err(MissingFieldError("type"));
1937            }
1938        })
1939    }
1940}
1941
1942impl TryFrom<bitwarden_api_api::models::CipherRepromptType> for CipherRepromptType {
1943    type Error = MissingFieldError;
1944
1945    fn try_from(t: bitwarden_api_api::models::CipherRepromptType) -> Result<Self, Self::Error> {
1946        Ok(match t {
1947            bitwarden_api_api::models::CipherRepromptType::None => CipherRepromptType::None,
1948            bitwarden_api_api::models::CipherRepromptType::Password => CipherRepromptType::Password,
1949            bitwarden_api_api::models::CipherRepromptType::__Unknown(_) => {
1950                return Err(MissingFieldError("reprompt"));
1951            }
1952        })
1953    }
1954}
1955
1956/// A trait for merging partial cipher data into a full cipher.
1957/// Used to convert from API response models to full Cipher structs,
1958/// without losing local data that may not be present in the API response.
1959pub(crate) trait PartialCipher {
1960    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError>;
1961}
1962
1963impl From<CipherType> for bitwarden_api_api::models::CipherType {
1964    fn from(t: CipherType) -> Self {
1965        match t {
1966            CipherType::Login => bitwarden_api_api::models::CipherType::Login,
1967            CipherType::SecureNote => bitwarden_api_api::models::CipherType::SecureNote,
1968            CipherType::Card => bitwarden_api_api::models::CipherType::Card,
1969            CipherType::Identity => bitwarden_api_api::models::CipherType::Identity,
1970            CipherType::SshKey => bitwarden_api_api::models::CipherType::SSHKey,
1971            CipherType::BankAccount => bitwarden_api_api::models::CipherType::BankAccount,
1972            CipherType::Passport => bitwarden_api_api::models::CipherType::Passport,
1973            CipherType::DriversLicense => bitwarden_api_api::models::CipherType::DriversLicense,
1974        }
1975    }
1976}
1977
1978impl From<CipherRepromptType> for bitwarden_api_api::models::CipherRepromptType {
1979    fn from(t: CipherRepromptType) -> Self {
1980        match t {
1981            CipherRepromptType::None => bitwarden_api_api::models::CipherRepromptType::None,
1982            CipherRepromptType::Password => bitwarden_api_api::models::CipherRepromptType::Password,
1983        }
1984    }
1985}
1986
1987impl PartialCipher for CipherResponseModel {
1988    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
1989        Ok(Cipher {
1990            collection_ids: cipher
1991                .as_ref()
1992                .map(|c| c.collection_ids.clone())
1993                .unwrap_or_default(),
1994            local_data: cipher.and_then(|c| c.local_data),
1995            id: self.id.map(CipherId::new),
1996            organization_id: self.organization_id.map(OrganizationId::new),
1997            folder_id: self.folder_id.map(FolderId::new),
1998            name: self.name.map(|n| n.parse()).transpose()?,
1999            notes: EncString::try_from_optional(self.notes)?,
2000            r#type: require!(self.r#type).try_into()?,
2001            login: self.login.map(|l| (*l).try_into()).transpose()?,
2002            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
2003            card: self.card.map(|c| (*c).try_into()).transpose()?,
2004            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
2005            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
2006            bank_account: self.bank_account.map(|b| (*b).try_into()).transpose()?,
2007            drivers_license: self.drivers_license.map(|d| (*d).try_into()).transpose()?,
2008            passport: self.passport.map(|p| (*p).try_into()).transpose()?,
2009            favorite: self.favorite.unwrap_or(false),
2010            reprompt: self
2011                .reprompt
2012                .map(|r| r.try_into())
2013                .transpose()?
2014                .unwrap_or(CipherRepromptType::None),
2015            organization_use_totp: self.organization_use_totp.unwrap_or(false),
2016            edit: self.edit.unwrap_or(false),
2017            permissions: self.permissions.map(|p| (*p).try_into()).transpose()?,
2018            view_password: self.view_password.unwrap_or(true),
2019            attachments: self
2020                .attachments
2021                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
2022                .transpose()?,
2023            fields: self
2024                .fields
2025                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
2026                .transpose()?,
2027            password_history: self
2028                .password_history
2029                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
2030                .transpose()?,
2031            creation_date: require!(self.creation_date).parse()?,
2032            deleted_date: self.deleted_date.map(|d| d.parse()).transpose()?,
2033            revision_date: require!(self.revision_date).parse()?,
2034            key: EncString::try_from_optional(self.key)?,
2035            archived_date: self.archived_date.map(|d| d.parse()).transpose()?,
2036            data: self.data,
2037        })
2038    }
2039}
2040
2041impl PartialCipher for CipherMiniResponseModel {
2042    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
2043        let cipher = cipher.as_ref();
2044        Ok(Cipher {
2045            id: self.id.map(CipherId::new),
2046            organization_id: self.organization_id.map(OrganizationId::new),
2047            key: EncString::try_from_optional(self.key)?,
2048            name: EncString::try_from_optional(self.name)?,
2049            notes: EncString::try_from_optional(self.notes)?,
2050            r#type: require!(self.r#type).try_into()?,
2051            login: self.login.map(|l| (*l).try_into()).transpose()?,
2052            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
2053            card: self.card.map(|c| (*c).try_into()).transpose()?,
2054            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
2055            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
2056            bank_account: self.bank_account.map(|b| (*b).try_into()).transpose()?,
2057            drivers_license: self.drivers_license.map(|d| (*d).try_into()).transpose()?,
2058            passport: self.passport.map(|p| (*p).try_into()).transpose()?,
2059            reprompt: self
2060                .reprompt
2061                .map(|r| r.try_into())
2062                .transpose()?
2063                .unwrap_or(CipherRepromptType::None),
2064            organization_use_totp: self.organization_use_totp.unwrap_or(true),
2065            attachments: self
2066                .attachments
2067                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
2068                .transpose()?,
2069            fields: self
2070                .fields
2071                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
2072                .transpose()?,
2073            password_history: self
2074                .password_history
2075                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
2076                .transpose()?,
2077            creation_date: require!(self.creation_date)
2078                .parse()
2079                .map_err(Into::<VaultParseError>::into)?,
2080            deleted_date: self
2081                .deleted_date
2082                .map(|d| d.parse())
2083                .transpose()
2084                .map_err(Into::<VaultParseError>::into)?,
2085            revision_date: require!(self.revision_date)
2086                .parse()
2087                .map_err(Into::<VaultParseError>::into)?,
2088            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
2089            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
2090            favorite: cipher.map_or(Default::default(), |c| c.favorite),
2091            edit: cipher.map_or(Default::default(), |c| c.edit),
2092            permissions: cipher.map_or(Default::default(), |c| c.permissions),
2093            view_password: cipher.is_none_or(|c| c.view_password),
2094            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
2095            data: self.data,
2096            collection_ids: cipher.map_or(Default::default(), |c| c.collection_ids.clone()),
2097        })
2098    }
2099}
2100
2101impl PartialCipher for CipherMiniDetailsResponseModel {
2102    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
2103        let cipher = cipher.as_ref();
2104        Ok(Cipher {
2105            id: self.id.map(CipherId::new),
2106            organization_id: self.organization_id.map(OrganizationId::new),
2107            key: EncString::try_from_optional(self.key)?,
2108            name: EncString::try_from_optional(self.name)?,
2109            notes: EncString::try_from_optional(self.notes)?,
2110            r#type: require!(self.r#type).try_into()?,
2111            login: self.login.map(|l| (*l).try_into()).transpose()?,
2112            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
2113            card: self.card.map(|c| (*c).try_into()).transpose()?,
2114            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
2115            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
2116            bank_account: self.bank_account.map(|b| (*b).try_into()).transpose()?,
2117            drivers_license: self.drivers_license.map(|d| (*d).try_into()).transpose()?,
2118            passport: self.passport.map(|p| (*p).try_into()).transpose()?,
2119            reprompt: self
2120                .reprompt
2121                .map(|r| r.try_into())
2122                .transpose()?
2123                .unwrap_or(CipherRepromptType::None),
2124            organization_use_totp: self.organization_use_totp.unwrap_or(true),
2125            attachments: self
2126                .attachments
2127                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
2128                .transpose()?,
2129            fields: self
2130                .fields
2131                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
2132                .transpose()?,
2133            password_history: self
2134                .password_history
2135                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
2136                .transpose()?,
2137            creation_date: require!(self.creation_date)
2138                .parse()
2139                .map_err(Into::<VaultParseError>::into)?,
2140            deleted_date: self
2141                .deleted_date
2142                .map(|d| d.parse())
2143                .transpose()
2144                .map_err(Into::<VaultParseError>::into)?,
2145            revision_date: require!(self.revision_date)
2146                .parse()
2147                .map_err(Into::<VaultParseError>::into)?,
2148            collection_ids: self
2149                .collection_ids
2150                .into_iter()
2151                .flatten()
2152                .map(CollectionId::new)
2153                .collect(),
2154            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
2155            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
2156            favorite: cipher.map_or(Default::default(), |c| c.favorite),
2157            edit: cipher.map_or(Default::default(), |c| c.edit),
2158            permissions: cipher.map_or(Default::default(), |c| c.permissions),
2159            view_password: cipher.is_none_or(|c: &Cipher| c.view_password),
2160            data: cipher.map_or(Default::default(), |c| c.data.clone()),
2161            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
2162        })
2163    }
2164}
2165
2166#[cfg(test)]
2167mod tests {
2168
2169    use attachment::AttachmentView;
2170    use bitwarden_core::key_management::{
2171        create_test_crypto_with_user_and_org_key, create_test_crypto_with_user_key,
2172    };
2173    use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
2174
2175    use super::*;
2176    use crate::{Fido2Credential, PasswordHistoryView, login::Fido2CredentialListView};
2177
2178    // Test constants for encrypted strings
2179    const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk=";
2180    const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=";
2181    const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
2182    const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=";
2183    const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=";
2184    const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
2185    const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69";
2186
2187    fn generate_cipher() -> CipherView {
2188        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
2189        CipherView {
2190            r#type: CipherType::Login,
2191            login: Some(LoginView {
2192                username: Some("test_username".to_string()),
2193                password: Some("test_password".to_string()),
2194                password_revision_date: None,
2195                uris: None,
2196                totp: None,
2197                autofill_on_page_load: None,
2198                fido2_credentials: None,
2199            }),
2200            id: Some(test_id),
2201            organization_id: None,
2202            folder_id: None,
2203            collection_ids: vec![],
2204            key: None,
2205            name: "My test login".to_string(),
2206            notes: None,
2207            identity: None,
2208            card: None,
2209            secure_note: None,
2210            ssh_key: None,
2211            bank_account: None,
2212            drivers_license: None,
2213            passport: None,
2214            favorite: false,
2215            reprompt: CipherRepromptType::None,
2216            organization_use_totp: true,
2217            edit: true,
2218            permissions: None,
2219            view_password: true,
2220            local_data: None,
2221            attachments: None,
2222            attachment_decryption_failures: None,
2223            fields: None,
2224            password_history: None,
2225            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2226            deleted_date: None,
2227            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2228            archived_date: None,
2229        }
2230    }
2231
2232    fn generate_fido2(
2233        ctx: &mut KeyStoreContext<KeySlotIds>,
2234        key: SymmetricKeySlotId,
2235    ) -> Fido2Credential {
2236        Fido2Credential {
2237            credential_id: "123".to_string().encrypt(ctx, key).unwrap(),
2238            key_type: "public-key".to_string().encrypt(ctx, key).unwrap(),
2239            key_algorithm: "ECDSA".to_string().encrypt(ctx, key).unwrap(),
2240            key_curve: "P-256".to_string().encrypt(ctx, key).unwrap(),
2241            key_value: "123".to_string().encrypt(ctx, key).unwrap(),
2242            rp_id: "123".to_string().encrypt(ctx, key).unwrap(),
2243            user_handle: None,
2244            user_name: None,
2245            counter: "123".to_string().encrypt(ctx, key).unwrap(),
2246            rp_name: None,
2247            user_display_name: None,
2248            discoverable: "true".to_string().encrypt(ctx, key).unwrap(),
2249            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
2250        }
2251    }
2252
2253    #[test]
2254    fn test_decrypt_cipher_list_view() {
2255        let key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
2256        let key_store = create_test_crypto_with_user_key(key);
2257
2258        let cipher = Cipher {
2259            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
2260            organization_id: None,
2261            folder_id: None,
2262            collection_ids: vec![],
2263            key: None,
2264            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
2265            notes: None,
2266            r#type: CipherType::Login,
2267            login: Some(Login {
2268                username: Some("2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=".parse().unwrap()),
2269                password: Some("2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=".parse().unwrap()),
2270                password_revision_date: None,
2271                uris: None,
2272                totp: Some("2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=".parse().unwrap()),
2273                autofill_on_page_load: None,
2274                fido2_credentials: Some(vec![generate_fido2(&mut key_store.context(), SymmetricKeySlotId::User)]),
2275            }),
2276            identity: None,
2277            card: None,
2278            secure_note: None,
2279            ssh_key: None,
2280            bank_account: None,
2281            drivers_license: None,
2282            passport: None,
2283            favorite: false,
2284            reprompt: CipherRepromptType::None,
2285            organization_use_totp: false,
2286            edit: true,
2287            permissions: Some(CipherPermissions {
2288                delete: false,
2289                restore: false
2290            }),
2291            view_password: true,
2292            local_data: None,
2293            attachments: None,
2294            fields: None,
2295            password_history: None,
2296            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2297            deleted_date: None,
2298            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2299            archived_date: None,
2300            data: None,
2301        };
2302
2303        let view: CipherListView = key_store.decrypt(&cipher).unwrap();
2304
2305        assert_eq!(
2306            view,
2307            CipherListView {
2308                id: cipher.id,
2309                organization_id: cipher.organization_id,
2310                folder_id: cipher.folder_id,
2311                collection_ids: cipher.collection_ids,
2312                key: cipher.key,
2313                name: "My test login".to_string(),
2314                subtitle: "test_username".to_string(),
2315                r#type: CipherListViewType::Login(LoginListView {
2316                    fido2_credentials: Some(vec![Fido2CredentialListView {
2317                        credential_id: "123".to_string(),
2318                        rp_id: "123".to_string(),
2319                        user_handle: None,
2320                        user_name: None,
2321                        user_display_name: None,
2322                        counter: "123".to_string(),
2323                    }]),
2324                    has_fido2: true,
2325                    username: Some("test_username".to_string()),
2326                    totp: cipher.login.as_ref().unwrap().totp.clone(),
2327                    uris: None,
2328                }),
2329                favorite: cipher.favorite,
2330                reprompt: cipher.reprompt,
2331                organization_use_totp: cipher.organization_use_totp,
2332                edit: cipher.edit,
2333                permissions: cipher.permissions,
2334                view_password: cipher.view_password,
2335                attachments: 0,
2336                has_old_attachments: false,
2337                creation_date: cipher.creation_date,
2338                deleted_date: cipher.deleted_date,
2339                revision_date: cipher.revision_date,
2340                copyable_fields: vec![
2341                    CopyableCipherFields::LoginUsername,
2342                    CopyableCipherFields::LoginPassword,
2343                    CopyableCipherFields::LoginTotp
2344                ],
2345                local_data: None,
2346                archived_date: cipher.archived_date,
2347                #[cfg(feature = "wasm")]
2348                notes: None,
2349                #[cfg(feature = "wasm")]
2350                fields: None,
2351                #[cfg(feature = "wasm")]
2352                attachment_names: None,
2353            }
2354        )
2355    }
2356
2357    fn blob_cipher() -> Cipher {
2358        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2359            SymmetricKeyAlgorithm::Aes256CbcHmac,
2360        ));
2361        let cipher: Cipher = key_store
2362            .encrypt(EncryptMode::Blob(generate_cipher()))
2363            .unwrap();
2364        assert!(cipher.data.is_some(), "expected a blob-shaped cipher");
2365        cipher
2366    }
2367
2368    #[test]
2369    fn test_encryption_context_to_cipher_with_id_request_preserves_data() {
2370        let cipher = blob_cipher();
2371        let expected = cipher.data.clone();
2372
2373        let request: CipherWithIdRequestModel = EncryptionContext {
2374            encrypted_for: UserId::new(TEST_UUID.parse().unwrap()),
2375            cipher,
2376        }
2377        .try_into()
2378        .unwrap();
2379
2380        assert_eq!(request.data, expected);
2381    }
2382
2383    #[test]
2384    fn test_encryption_context_to_cipher_request_preserves_data() {
2385        let cipher = blob_cipher();
2386        let expected = cipher.data.clone();
2387
2388        let request: CipherRequestModel = EncryptionContext {
2389            encrypted_for: UserId::new(TEST_UUID.parse().unwrap()),
2390            cipher,
2391        }
2392        .into();
2393
2394        assert_eq!(request.data, expected);
2395    }
2396
2397    #[test]
2398    fn test_decrypt_cipher_fails_with_invalid_name() {
2399        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2400            SymmetricKeyAlgorithm::Aes256CbcHmac,
2401        ));
2402
2403        // Encrypt a valid cipher, then swap name with an EncString from a different key
2404        let cipher = key_store
2405            .encrypt(EncryptMode::Legacy(generate_cipher()))
2406            .unwrap();
2407        let cipher = Cipher {
2408            name: Some(TEST_CIPHER_NAME.parse().unwrap()), // encrypted with a different key
2409            ..cipher
2410        };
2411
2412        // Default (lenient) decryption swallows the error, yielding an empty name
2413        let lenient_result: Result<CipherView, _> = key_store.decrypt(&cipher);
2414        assert!(
2415            lenient_result.is_ok(),
2416            "Lenient decryption should succeed even when name is encrypted with a different key"
2417        );
2418        assert_eq!(
2419            lenient_result.unwrap().name,
2420            String::new(),
2421            "Lenient decryption should yield an empty name on error"
2422        );
2423
2424        // Strict decryption propagates the error
2425        let strict_result: Result<CipherView, _> = key_store.decrypt(&StrictDecrypt(cipher));
2426        assert!(
2427            strict_result.is_err(),
2428            "Strict decryption should fail when name is encrypted with a different key"
2429        );
2430    }
2431
2432    #[test]
2433    fn test_decrypt_cipher_fails_with_invalid_login() {
2434        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2435            SymmetricKeyAlgorithm::Aes256CbcHmac,
2436        ));
2437
2438        // Encrypt a valid cipher, then corrupt the login username
2439        let cipher = key_store
2440            .encrypt(EncryptMode::Legacy(generate_cipher()))
2441            .unwrap();
2442        let cipher = Cipher {
2443            login: Some(Login {
2444                username: Some(TEST_CIPHER_NAME.parse().unwrap()), // encrypted with a different key
2445                ..cipher.login.unwrap()
2446            }),
2447            ..cipher
2448        };
2449
2450        // Default (lenient) decryption swallows the error, yielding None for the username field
2451        let lenient_result: Result<CipherView, _> = key_store.decrypt(&cipher);
2452        assert!(
2453            lenient_result.is_ok(),
2454            "Lenient decryption should succeed even when login username is encrypted with a different key"
2455        );
2456        let lenient_view = lenient_result.unwrap();
2457        assert!(
2458            lenient_view.login.is_some(),
2459            "Lenient decryption should still return the login object"
2460        );
2461        assert!(
2462            lenient_view.login.unwrap().username.is_none(),
2463            "Lenient decryption should null out the failing username field"
2464        );
2465
2466        // Strict decryption propagates the error
2467        let strict_result: Result<CipherView, _> = key_store.decrypt(&StrictDecrypt(cipher));
2468        assert!(
2469            strict_result.is_err(),
2470            "Strict decryption should fail when login username is encrypted with a different key"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_generate_cipher_key() {
2476        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2477        let key_store = create_test_crypto_with_user_key(key);
2478
2479        let original_cipher = generate_cipher();
2480
2481        // Check that the cipher gets encrypted correctly without it's own key
2482        let cipher = generate_cipher();
2483        let no_key_cipher_enc = key_store.encrypt(EncryptMode::Legacy(cipher)).unwrap();
2484        let no_key_cipher_dec: CipherView = key_store.decrypt(&no_key_cipher_enc).unwrap();
2485        assert!(no_key_cipher_dec.key.is_none());
2486        assert_eq!(no_key_cipher_dec.name, original_cipher.name);
2487
2488        let mut cipher = generate_cipher();
2489        cipher
2490            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
2491            .unwrap();
2492
2493        // Check that the cipher gets encrypted correctly when it's assigned it's own key
2494        let key_cipher_enc = key_store.encrypt(EncryptMode::Legacy(cipher)).unwrap();
2495        let key_cipher_dec: CipherView = key_store.decrypt(&key_cipher_enc).unwrap();
2496        assert!(key_cipher_dec.key.is_some());
2497        assert_eq!(key_cipher_dec.name, original_cipher.name);
2498    }
2499
2500    #[test]
2501    fn test_generate_cipher_key_when_a_cipher_key_already_exists() {
2502        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2503        let key_store = create_test_crypto_with_user_key(key);
2504
2505        let mut original_cipher = generate_cipher();
2506        {
2507            let mut ctx = key_store.context();
2508            let cipher_key = ctx.generate_symmetric_key();
2509
2510            original_cipher.key = Some(
2511                ctx.wrap_symmetric_key(SymmetricKeySlotId::User, cipher_key)
2512                    .unwrap(),
2513            );
2514        }
2515
2516        original_cipher
2517            .generate_cipher_key(&mut key_store.context(), original_cipher.key_identifier())
2518            .unwrap();
2519
2520        // Make sure that the cipher key is decryptable
2521        let wrapped_key = original_cipher.key.unwrap();
2522        let mut ctx = key_store.context();
2523        let _ = ctx
2524            .unwrap_symmetric_key(SymmetricKeySlotId::User, &wrapped_key)
2525            .unwrap();
2526    }
2527
2528    #[test]
2529    fn test_generate_cipher_key_ignores_attachments_without_key() {
2530        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2531        let key_store = create_test_crypto_with_user_key(key);
2532
2533        let mut cipher = generate_cipher();
2534        let attachment = AttachmentView {
2535            id: None,
2536            url: None,
2537            size: None,
2538            size_name: None,
2539            file_name: Some("Attachment test name".into()),
2540            key: None,
2541            #[cfg(feature = "wasm")]
2542            decrypted_key: None,
2543        };
2544        cipher.attachments = Some(vec![attachment]);
2545
2546        cipher
2547            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
2548            .unwrap();
2549        assert!(cipher.attachments.unwrap()[0].key.is_none());
2550    }
2551
2552    #[test]
2553    fn test_reencrypt_cipher_key() {
2554        let old_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2555        let new_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2556        let key_store = create_test_crypto_with_user_key(old_key);
2557        let mut ctx = key_store.context_mut();
2558
2559        let mut cipher = generate_cipher();
2560        cipher
2561            .generate_cipher_key(&mut ctx, cipher.key_identifier())
2562            .unwrap();
2563
2564        // Re-encrypt the cipher key with a new wrapping key
2565        let new_key_id = ctx.add_local_symmetric_key(new_key);
2566
2567        cipher.reencrypt_cipher_keys(&mut ctx, new_key_id).unwrap();
2568
2569        // Check that the cipher key can be unwrapped with the new key
2570        assert!(cipher.key.is_some());
2571        assert!(
2572            ctx.unwrap_symmetric_key(new_key_id, &cipher.key.unwrap())
2573                .is_ok()
2574        );
2575    }
2576
2577    #[test]
2578    fn test_reencrypt_cipher_key_ignores_missing_key() {
2579        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2580        let key_store = create_test_crypto_with_user_key(key);
2581        let mut ctx = key_store.context_mut();
2582        let mut cipher = generate_cipher();
2583
2584        // The cipher does not have a key, so re-encryption should not add one
2585        let new_cipher_key = ctx.generate_symmetric_key();
2586        cipher
2587            .reencrypt_cipher_keys(&mut ctx, new_cipher_key)
2588            .unwrap();
2589
2590        // Check that the cipher key is still None
2591        assert!(cipher.key.is_none());
2592    }
2593
2594    #[test]
2595    fn test_move_user_cipher_to_org() {
2596        let org = OrganizationId::new_v4();
2597        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2598        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2599        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
2600
2601        // Create a cipher with a user key
2602        let mut cipher = generate_cipher();
2603        cipher
2604            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
2605            .unwrap();
2606
2607        cipher
2608            .move_to_organization(&mut key_store.context(), org)
2609            .unwrap();
2610        assert_eq!(cipher.organization_id, Some(org));
2611
2612        // Check that the cipher can be encrypted/decrypted with the new org key
2613        let cipher_enc = key_store.encrypt(EncryptMode::Legacy(cipher)).unwrap();
2614        let cipher_dec: CipherView = key_store.decrypt(&cipher_enc).unwrap();
2615
2616        assert_eq!(cipher_dec.name, "My test login");
2617    }
2618
2619    #[test]
2620    fn test_move_user_cipher_to_org_manually() {
2621        let org = OrganizationId::new_v4();
2622        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2623        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2624        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
2625
2626        // Create a cipher with a user key
2627        let mut cipher = generate_cipher();
2628        cipher
2629            .generate_cipher_key(&mut key_store.context(), cipher.key_identifier())
2630            .unwrap();
2631
2632        cipher.organization_id = Some(org);
2633
2634        // Check that the cipher can not be encrypted, as the
2635        // cipher key is tied to the user key and not the org key
2636        assert!(key_store.encrypt(EncryptMode::Legacy(cipher)).is_err());
2637    }
2638
2639    #[test]
2640    fn test_move_user_cipher_with_attachment_without_key_to_org() {
2641        let org = OrganizationId::new_v4();
2642        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2643        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2644        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
2645
2646        let mut cipher = generate_cipher();
2647        let attachment = AttachmentView {
2648            id: None,
2649            url: None,
2650            size: None,
2651            size_name: None,
2652            file_name: Some("Attachment test name".into()),
2653            key: None,
2654            #[cfg(feature = "wasm")]
2655            decrypted_key: None,
2656        };
2657        cipher.attachments = Some(vec![attachment]);
2658
2659        // Neither cipher nor attachment have keys, so the cipher can't be moved
2660        assert!(
2661            cipher
2662                .move_to_organization(&mut key_store.context(), org)
2663                .is_err()
2664        );
2665    }
2666
2667    #[test]
2668    fn test_move_user_cipher_with_attachment_with_key_to_org() {
2669        let org = OrganizationId::new_v4();
2670        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2671        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2672        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
2673        let org_key = SymmetricKeySlotId::Organization(org);
2674
2675        // Attachment has a key that is encrypted with the user key, as the cipher has no key itself
2676        let (attachment_key_enc, attachment_key_val) = {
2677            let mut ctx = key_store.context();
2678            let attachment_key = ctx.generate_symmetric_key();
2679            let attachment_key_enc = ctx
2680                .wrap_symmetric_key(SymmetricKeySlotId::User, attachment_key)
2681                .unwrap();
2682            #[allow(deprecated)]
2683            let attachment_key_val = ctx
2684                .dangerous_get_symmetric_key(attachment_key)
2685                .unwrap()
2686                .clone();
2687
2688            (attachment_key_enc, attachment_key_val)
2689        };
2690
2691        let mut cipher = generate_cipher();
2692        let attachment = AttachmentView {
2693            id: None,
2694            url: None,
2695            size: None,
2696            size_name: None,
2697            file_name: Some("Attachment test name".into()),
2698            key: Some(attachment_key_enc),
2699            #[cfg(feature = "wasm")]
2700            decrypted_key: None,
2701        };
2702        cipher.attachments = Some(vec![attachment]);
2703        let cred = generate_fido2(&mut key_store.context(), SymmetricKeySlotId::User);
2704        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred]);
2705
2706        cipher
2707            .move_to_organization(&mut key_store.context(), org)
2708            .unwrap();
2709
2710        assert!(cipher.key.is_none());
2711
2712        // Check that the attachment key has been re-encrypted with the org key,
2713        // and the value matches with the original attachment key
2714        let new_attachment_key = cipher.attachments.unwrap()[0].key.clone().unwrap();
2715        let mut ctx = key_store.context();
2716        let new_attachment_key_id = ctx
2717            .unwrap_symmetric_key(org_key, &new_attachment_key)
2718            .unwrap();
2719        #[allow(deprecated)]
2720        let new_attachment_key_dec = ctx
2721            .dangerous_get_symmetric_key(new_attachment_key_id)
2722            .unwrap();
2723
2724        assert_eq!(*new_attachment_key_dec, attachment_key_val);
2725
2726        let cred2: Fido2CredentialFullView = cipher
2727            .login
2728            .unwrap()
2729            .fido2_credentials
2730            .unwrap()
2731            .first()
2732            .unwrap()
2733            .decrypt(&mut key_store.context(), org_key)
2734            .unwrap();
2735
2736        assert_eq!(cred2.credential_id, "123");
2737    }
2738
2739    #[test]
2740    fn test_move_user_cipher_with_key_with_attachment_with_key_to_org() {
2741        let org = OrganizationId::new_v4();
2742        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2743        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2744        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
2745        let org_key = SymmetricKeySlotId::Organization(org);
2746
2747        let mut ctx = key_store.context();
2748
2749        let cipher_key = ctx.generate_symmetric_key();
2750        let cipher_key_enc = ctx
2751            .wrap_symmetric_key(SymmetricKeySlotId::User, cipher_key)
2752            .unwrap();
2753
2754        // Attachment has a key that is encrypted with the cipher key
2755        let attachment_key = ctx.generate_symmetric_key();
2756        let attachment_key_enc = ctx.wrap_symmetric_key(cipher_key, attachment_key).unwrap();
2757
2758        let mut cipher = generate_cipher();
2759        cipher.key = Some(cipher_key_enc);
2760
2761        let attachment = AttachmentView {
2762            id: None,
2763            url: None,
2764            size: None,
2765            size_name: None,
2766            file_name: Some("Attachment test name".into()),
2767            key: Some(attachment_key_enc.clone()),
2768            #[cfg(feature = "wasm")]
2769            decrypted_key: None,
2770        };
2771        cipher.attachments = Some(vec![attachment]);
2772
2773        let cred = generate_fido2(&mut ctx, cipher_key);
2774        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred.clone()]);
2775
2776        cipher.move_to_organization(&mut ctx, org).unwrap();
2777
2778        // Check that the cipher key has been re-encrypted with the org key,
2779        let wrapped_new_cipher_key = cipher.key.clone().unwrap();
2780        let new_cipher_key_dec = ctx
2781            .unwrap_symmetric_key(org_key, &wrapped_new_cipher_key)
2782            .unwrap();
2783        #[allow(deprecated)]
2784        let new_cipher_key_dec = ctx.dangerous_get_symmetric_key(new_cipher_key_dec).unwrap();
2785        #[allow(deprecated)]
2786        let cipher_key_val = ctx.dangerous_get_symmetric_key(cipher_key).unwrap();
2787
2788        assert_eq!(new_cipher_key_dec, cipher_key_val);
2789
2790        // Check that the attachment key hasn't changed
2791        assert_eq!(
2792            cipher.attachments.unwrap()[0]
2793                .key
2794                .as_ref()
2795                .unwrap()
2796                .to_string(),
2797            attachment_key_enc.to_string()
2798        );
2799
2800        let cred2: Fido2Credential = cipher
2801            .login
2802            .unwrap()
2803            .fido2_credentials
2804            .unwrap()
2805            .first()
2806            .unwrap()
2807            .clone();
2808
2809        assert_eq!(
2810            cred2.credential_id.to_string(),
2811            cred.credential_id.to_string()
2812        );
2813    }
2814
2815    #[test]
2816    fn test_decrypt_fido2_private_key() {
2817        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2818            SymmetricKeyAlgorithm::Aes256CbcHmac,
2819        ));
2820        let mut ctx = key_store.context();
2821
2822        let mut cipher_view = generate_cipher();
2823        cipher_view
2824            .generate_cipher_key(&mut ctx, cipher_view.key_identifier())
2825            .unwrap();
2826
2827        let key_id = cipher_view.key_identifier();
2828        let ciphers_key = Cipher::decrypt_cipher_key(&mut ctx, key_id, &cipher_view.key).unwrap();
2829
2830        let fido2_credential = generate_fido2(&mut ctx, ciphers_key);
2831
2832        cipher_view.login.as_mut().unwrap().fido2_credentials =
2833            Some(vec![fido2_credential.clone()]);
2834
2835        let decrypted_key_value = cipher_view.decrypt_fido2_private_key(&mut ctx).unwrap();
2836        assert_eq!(decrypted_key_value, "123");
2837    }
2838
2839    #[test]
2840    fn test_password_history_on_password_change() {
2841        use chrono::Utc;
2842
2843        let original_cipher = generate_cipher();
2844        let mut new_cipher = generate_cipher();
2845
2846        // Change password
2847        if let Some(ref mut login) = new_cipher.login {
2848            login.password = Some("new_password123".to_string());
2849        }
2850
2851        let start = Utc::now();
2852        new_cipher.update_password_history(&original_cipher);
2853        let end = Utc::now();
2854
2855        assert!(new_cipher.password_history.is_some());
2856        let history = new_cipher.password_history.unwrap();
2857        assert_eq!(history.len(), 1);
2858        assert_eq!(history[0].password, "test_password");
2859        assert!(
2860            history[0].last_used_date >= start && history[0].last_used_date <= end,
2861            "last_used_date was not set properly"
2862        );
2863    }
2864
2865    #[test]
2866    fn test_password_history_on_unchanged_password() {
2867        let original_cipher = generate_cipher();
2868        let mut new_cipher = generate_cipher();
2869
2870        new_cipher.update_password_history(&original_cipher);
2871
2872        // Password history should be empty since password didn't change
2873        assert!(
2874            new_cipher.password_history.is_none()
2875                || new_cipher.password_history.as_ref().unwrap().is_empty()
2876        );
2877    }
2878
2879    #[test]
2880    fn test_password_history_is_preserved() {
2881        use chrono::TimeZone;
2882
2883        let mut original_cipher = generate_cipher();
2884        original_cipher.password_history = Some(
2885            (0..4)
2886                .map(|i| PasswordHistoryView {
2887                    password: format!("old_password_{}", i),
2888                    last_used_date: chrono::Utc
2889                        .with_ymd_and_hms(2025, i + 1, i + 1, i, i, i)
2890                        .unwrap(),
2891                })
2892                .collect(),
2893        );
2894
2895        let mut new_cipher = generate_cipher();
2896
2897        new_cipher.update_password_history(&original_cipher);
2898
2899        assert!(new_cipher.password_history.is_some());
2900        let history = new_cipher.password_history.unwrap();
2901        assert_eq!(history.len(), 4);
2902
2903        assert_eq!(history[0].password, "old_password_0");
2904        assert_eq!(
2905            history[0].last_used_date,
2906            chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
2907        );
2908        assert_eq!(history[1].password, "old_password_1");
2909        assert_eq!(
2910            history[1].last_used_date,
2911            chrono::Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
2912        );
2913        assert_eq!(history[2].password, "old_password_2");
2914        assert_eq!(
2915            history[2].last_used_date,
2916            chrono::Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
2917        );
2918        assert_eq!(history[3].password, "old_password_3");
2919        assert_eq!(
2920            history[3].last_used_date,
2921            chrono::Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
2922        );
2923    }
2924
2925    #[test]
2926    fn test_populate_cipher_types_login_with_valid_data() {
2927        let mut cipher = Cipher {
2928            id: Some(TEST_UUID.parse().unwrap()),
2929            organization_id: None,
2930            folder_id: None,
2931            collection_ids: vec![],
2932            key: None,
2933            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
2934            notes: None,
2935            r#type: CipherType::Login,
2936            login: None,
2937            identity: None,
2938            card: None,
2939            secure_note: None,
2940            ssh_key: None,
2941            bank_account: None,
2942            drivers_license: None,
2943            passport: None,
2944            favorite: false,
2945            reprompt: CipherRepromptType::None,
2946            organization_use_totp: false,
2947            edit: true,
2948            view_password: true,
2949            permissions: None,
2950            local_data: None,
2951            attachments: None,
2952            fields: None,
2953            password_history: None,
2954            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2955            deleted_date: None,
2956            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2957            archived_date: None,
2958            data: Some(format!(
2959                r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
2960                TEST_ENC_STRING_1, TEST_ENC_STRING_2
2961            )),
2962        };
2963
2964        cipher
2965            .populate_cipher_types()
2966            .expect("populate_cipher_types failed");
2967
2968        assert!(cipher.login.is_some());
2969        let login = cipher.login.unwrap();
2970        assert_eq!(login.username.unwrap().to_string(), TEST_ENC_STRING_1);
2971        assert_eq!(login.password.unwrap().to_string(), TEST_ENC_STRING_2);
2972    }
2973
2974    #[test]
2975    fn test_populate_cipher_types_secure_note() {
2976        let mut cipher = Cipher {
2977            id: Some(TEST_UUID.parse().unwrap()),
2978            organization_id: None,
2979            folder_id: None,
2980            collection_ids: vec![],
2981            key: None,
2982            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
2983            notes: None,
2984            r#type: CipherType::SecureNote,
2985            login: None,
2986            identity: None,
2987            card: None,
2988            secure_note: None,
2989            ssh_key: None,
2990            bank_account: None,
2991            drivers_license: None,
2992            passport: None,
2993            favorite: false,
2994            reprompt: CipherRepromptType::None,
2995            organization_use_totp: false,
2996            edit: true,
2997            view_password: true,
2998            permissions: None,
2999            local_data: None,
3000            attachments: None,
3001            fields: None,
3002            password_history: None,
3003            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3004            deleted_date: None,
3005            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3006            archived_date: None,
3007            data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()),
3008        };
3009
3010        cipher
3011            .populate_cipher_types()
3012            .expect("populate_cipher_types failed");
3013
3014        assert!(cipher.secure_note.is_some());
3015    }
3016
3017    #[test]
3018    fn test_populate_cipher_types_card() {
3019        let mut cipher = Cipher {
3020            id: Some(TEST_UUID.parse().unwrap()),
3021            organization_id: None,
3022            folder_id: None,
3023            collection_ids: vec![],
3024            key: None,
3025            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3026            notes: None,
3027            r#type: CipherType::Card,
3028            login: None,
3029            identity: None,
3030            card: None,
3031            secure_note: None,
3032            ssh_key: None,
3033            bank_account: None,
3034            drivers_license: None,
3035            passport: None,
3036            favorite: false,
3037            reprompt: CipherRepromptType::None,
3038            organization_use_totp: false,
3039            edit: true,
3040            view_password: true,
3041            permissions: None,
3042            local_data: None,
3043            attachments: None,
3044            fields: None,
3045            password_history: None,
3046            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3047            deleted_date: None,
3048            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3049            archived_date: None,
3050            data: Some(format!(
3051                r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
3052                TEST_ENC_STRING_1,
3053                TEST_ENC_STRING_2,
3054                TEST_ENC_STRING_3,
3055                TEST_ENC_STRING_4,
3056                TEST_ENC_STRING_5,
3057                TEST_ENC_STRING_1
3058            )),
3059        };
3060
3061        cipher
3062            .populate_cipher_types()
3063            .expect("populate_cipher_types failed");
3064
3065        assert!(cipher.card.is_some());
3066        let card = cipher.card.unwrap();
3067        assert_eq!(
3068            card.cardholder_name.as_ref().unwrap().to_string(),
3069            TEST_ENC_STRING_1
3070        );
3071        assert_eq!(card.number.as_ref().unwrap().to_string(), TEST_ENC_STRING_2);
3072        assert_eq!(
3073            card.exp_month.as_ref().unwrap().to_string(),
3074            TEST_ENC_STRING_3
3075        );
3076        assert_eq!(
3077            card.exp_year.as_ref().unwrap().to_string(),
3078            TEST_ENC_STRING_4
3079        );
3080        assert_eq!(card.code.as_ref().unwrap().to_string(), TEST_ENC_STRING_5);
3081        assert_eq!(card.brand.as_ref().unwrap().to_string(), TEST_ENC_STRING_1);
3082    }
3083
3084    #[test]
3085    fn test_populate_cipher_types_identity() {
3086        let mut cipher = Cipher {
3087            id: Some(TEST_UUID.parse().unwrap()),
3088            organization_id: None,
3089            folder_id: None,
3090            collection_ids: vec![],
3091            key: None,
3092            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3093            notes: None,
3094            r#type: CipherType::Identity,
3095            login: None,
3096            identity: None,
3097            card: None,
3098            secure_note: None,
3099            ssh_key: None,
3100            bank_account: None,
3101            drivers_license: None,
3102            passport: None,
3103            favorite: false,
3104            reprompt: CipherRepromptType::None,
3105            organization_use_totp: false,
3106            edit: true,
3107            view_password: true,
3108            permissions: None,
3109            local_data: None,
3110            attachments: None,
3111            fields: None,
3112            password_history: None,
3113            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3114            deleted_date: None,
3115            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3116            archived_date: None,
3117            data: Some(format!(
3118                r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#,
3119                TEST_ENC_STRING_1,
3120                TEST_ENC_STRING_2,
3121                TEST_ENC_STRING_3,
3122                TEST_ENC_STRING_4,
3123                TEST_ENC_STRING_5,
3124                TEST_ENC_STRING_1,
3125                TEST_ENC_STRING_2,
3126                TEST_ENC_STRING_3,
3127                TEST_ENC_STRING_4,
3128                TEST_ENC_STRING_5
3129            )),
3130        };
3131
3132        cipher
3133            .populate_cipher_types()
3134            .expect("populate_cipher_types failed");
3135
3136        assert!(cipher.identity.is_some());
3137        let identity = cipher.identity.unwrap();
3138        assert_eq!(
3139            identity.first_name.as_ref().unwrap().to_string(),
3140            TEST_ENC_STRING_1
3141        );
3142        assert_eq!(
3143            identity.last_name.as_ref().unwrap().to_string(),
3144            TEST_ENC_STRING_2
3145        );
3146        assert_eq!(
3147            identity.email.as_ref().unwrap().to_string(),
3148            TEST_ENC_STRING_3
3149        );
3150        assert_eq!(
3151            identity.phone.as_ref().unwrap().to_string(),
3152            TEST_ENC_STRING_4
3153        );
3154        assert_eq!(
3155            identity.company.as_ref().unwrap().to_string(),
3156            TEST_ENC_STRING_5
3157        );
3158        assert_eq!(
3159            identity.address1.as_ref().unwrap().to_string(),
3160            TEST_ENC_STRING_1
3161        );
3162        assert_eq!(
3163            identity.city.as_ref().unwrap().to_string(),
3164            TEST_ENC_STRING_2
3165        );
3166        assert_eq!(
3167            identity.state.as_ref().unwrap().to_string(),
3168            TEST_ENC_STRING_3
3169        );
3170        assert_eq!(
3171            identity.postal_code.as_ref().unwrap().to_string(),
3172            TEST_ENC_STRING_4
3173        );
3174        assert_eq!(
3175            identity.country.as_ref().unwrap().to_string(),
3176            TEST_ENC_STRING_5
3177        );
3178    }
3179
3180    #[test]
3181
3182    fn test_password_history_with_hidden_fields() {
3183        let mut original_cipher = generate_cipher();
3184        original_cipher.fields = Some(vec![FieldView {
3185            name: Some("Secret Key".to_string()),
3186            value: Some("old_secret_value".to_string()),
3187            r#type: crate::FieldType::Hidden,
3188            linked_id: None,
3189        }]);
3190
3191        let mut new_cipher = generate_cipher();
3192        new_cipher.fields = Some(vec![FieldView {
3193            name: Some("Secret Key".to_string()),
3194            value: Some("new_secret_value".to_string()),
3195            r#type: crate::FieldType::Hidden,
3196            linked_id: None,
3197        }]);
3198
3199        new_cipher.update_password_history(&original_cipher);
3200
3201        assert!(new_cipher.password_history.is_some());
3202        let history = new_cipher.password_history.unwrap();
3203        assert_eq!(history.len(), 1);
3204        assert_eq!(history[0].password, "Secret Key: old_secret_value");
3205    }
3206
3207    #[test]
3208    fn test_password_history_length_limit() {
3209        use crate::password_history::MAX_PASSWORD_HISTORY_ENTRIES;
3210
3211        let mut original_cipher = generate_cipher();
3212        original_cipher.password_history = Some(
3213            (0..10)
3214                .map(|i| PasswordHistoryView {
3215                    password: format!("old_password_{}", i),
3216                    last_used_date: chrono::Utc::now(),
3217                })
3218                .collect(),
3219        );
3220
3221        let mut new_cipher = original_cipher.clone();
3222        // Change password
3223        if let Some(ref mut login) = new_cipher.login {
3224            login.password = Some("brand_new_password".to_string());
3225        }
3226
3227        new_cipher.update_password_history(&original_cipher);
3228
3229        assert!(new_cipher.password_history.is_some());
3230        let history = new_cipher.password_history.unwrap();
3231
3232        // Should be limited to MAX_PASSWORD_HISTORY_ENTRIES
3233        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
3234
3235        // Most recent change (original password) should be first
3236        assert_eq!(history[0].password, "test_password");
3237        // Followed by the oldest entries from the existing history
3238        assert_eq!(history[1].password, "old_password_0");
3239        assert_eq!(history[2].password, "old_password_1");
3240        assert_eq!(history[3].password, "old_password_2");
3241        assert_eq!(history[4].password, "old_password_3");
3242    }
3243
3244    #[test]
3245    fn test_populate_cipher_types_ssh_key() {
3246        let mut cipher = Cipher {
3247            id: Some(TEST_UUID.parse().unwrap()),
3248            organization_id: None,
3249            folder_id: None,
3250            collection_ids: vec![],
3251            key: None,
3252            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3253            notes: None,
3254            r#type: CipherType::SshKey,
3255            login: None,
3256            identity: None,
3257            card: None,
3258            secure_note: None,
3259            ssh_key: None,
3260            bank_account: None,
3261            drivers_license: None,
3262            passport: None,
3263            favorite: false,
3264            reprompt: CipherRepromptType::None,
3265            organization_use_totp: false,
3266            edit: true,
3267            view_password: true,
3268            permissions: None,
3269            local_data: None,
3270            attachments: None,
3271            fields: None,
3272            password_history: None,
3273            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3274            deleted_date: None,
3275            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3276            archived_date: None,
3277            data: Some(format!(
3278                r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
3279                TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3
3280            )),
3281        };
3282
3283        cipher
3284            .populate_cipher_types()
3285            .expect("populate_cipher_types failed");
3286
3287        assert!(cipher.ssh_key.is_some());
3288        let ssh_key = cipher.ssh_key.unwrap();
3289        assert_eq!(ssh_key.private_key.to_string(), TEST_ENC_STRING_1);
3290        assert_eq!(ssh_key.public_key.unwrap().to_string(), TEST_ENC_STRING_2);
3291        assert_eq!(ssh_key.fingerprint.unwrap().to_string(), TEST_ENC_STRING_3);
3292    }
3293
3294    #[test]
3295    fn test_populate_cipher_types_with_null_data() {
3296        let mut cipher = Cipher {
3297            id: Some(TEST_UUID.parse().unwrap()),
3298            organization_id: None,
3299            folder_id: None,
3300            collection_ids: vec![],
3301            key: None,
3302            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3303            notes: None,
3304            r#type: CipherType::Login,
3305            login: None,
3306            identity: None,
3307            card: None,
3308            secure_note: None,
3309            ssh_key: None,
3310            bank_account: None,
3311            drivers_license: None,
3312            passport: None,
3313            favorite: false,
3314            reprompt: CipherRepromptType::None,
3315            organization_use_totp: false,
3316            edit: true,
3317            view_password: true,
3318            permissions: None,
3319            local_data: None,
3320            attachments: None,
3321            fields: None,
3322            password_history: None,
3323            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3324            deleted_date: None,
3325            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3326            archived_date: None,
3327            data: None,
3328        };
3329
3330        let result = cipher.populate_cipher_types();
3331        assert!(matches!(
3332            result,
3333            Err(VaultParseError::MissingField(MissingFieldError("data")))
3334        ));
3335    }
3336
3337    #[test]
3338    fn test_populate_cipher_types_with_invalid_json() {
3339        let mut cipher = Cipher {
3340            id: Some(TEST_UUID.parse().unwrap()),
3341            organization_id: None,
3342            folder_id: None,
3343            collection_ids: vec![],
3344            key: None,
3345            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3346            notes: None,
3347            r#type: CipherType::Login,
3348            login: None,
3349            identity: None,
3350            card: None,
3351            secure_note: None,
3352            ssh_key: None,
3353            bank_account: None,
3354            drivers_license: None,
3355            passport: None,
3356            favorite: false,
3357            reprompt: CipherRepromptType::None,
3358            organization_use_totp: false,
3359            edit: true,
3360            view_password: true,
3361            permissions: None,
3362            local_data: None,
3363            attachments: None,
3364            fields: None,
3365            password_history: None,
3366            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3367            deleted_date: None,
3368            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3369            archived_date: None,
3370            data: Some("invalid json".to_string()),
3371        };
3372
3373        let result = cipher.populate_cipher_types();
3374
3375        assert!(matches!(result, Err(VaultParseError::SerdeJson(_))));
3376    }
3377
3378    #[test]
3379    fn test_decrypt_cipher_with_mixed_attachments() {
3380        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
3381        let key_store = create_test_crypto_with_user_key(user_key);
3382
3383        // Create properly encrypted attachments
3384        let mut ctx = key_store.context();
3385        let valid1 = "valid_file_1.txt"
3386            .encrypt(&mut ctx, SymmetricKeySlotId::User)
3387            .unwrap();
3388        let valid2 = "valid_file_2.txt"
3389            .encrypt(&mut ctx, SymmetricKeySlotId::User)
3390            .unwrap();
3391
3392        // Create corrupted attachment by encrypting with a random different key
3393        let wrong_key: SymmetricCryptoKey = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==".to_string().try_into().unwrap();
3394        let wrong_key_store = create_test_crypto_with_user_key(wrong_key);
3395        let mut wrong_ctx = wrong_key_store.context();
3396        let corrupted = "corrupted_file.txt"
3397            .encrypt(&mut wrong_ctx, SymmetricKeySlotId::User)
3398            .unwrap();
3399
3400        let cipher = Cipher {
3401            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
3402            organization_id: None,
3403            folder_id: None,
3404            collection_ids: vec![],
3405            key: None,
3406            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3407            notes: None,
3408            r#type: CipherType::Login,
3409            login: None,
3410            identity: None,
3411            card: None,
3412            secure_note: None,
3413            ssh_key: None,
3414            bank_account: None,
3415            drivers_license: None,
3416            passport: None,
3417            favorite: false,
3418            reprompt: CipherRepromptType::None,
3419            organization_use_totp: false,
3420            edit: true,
3421            permissions: None,
3422            view_password: true,
3423            local_data: None,
3424            attachments: Some(vec![
3425                // Valid attachment
3426                attachment::Attachment {
3427                    id: Some("valid-attachment".to_string()),
3428                    url: Some("https://example.com/valid".to_string()),
3429                    size: Some("100".to_string()),
3430                    size_name: Some("100 Bytes".to_string()),
3431                    file_name: Some(valid1),
3432                    key: None,
3433                },
3434                // Corrupted attachment
3435                attachment::Attachment {
3436                    id: Some("corrupted-attachment".to_string()),
3437                    url: Some("https://example.com/corrupted".to_string()),
3438                    size: Some("200".to_string()),
3439                    size_name: Some("200 Bytes".to_string()),
3440                    file_name: Some(corrupted),
3441                    key: None,
3442                },
3443                // Another valid attachment
3444                attachment::Attachment {
3445                    id: Some("valid-attachment-2".to_string()),
3446                    url: Some("https://example.com/valid2".to_string()),
3447                    size: Some("150".to_string()),
3448                    size_name: Some("150 Bytes".to_string()),
3449                    file_name: Some(valid2),
3450                    key: None,
3451                },
3452            ]),
3453            fields: None,
3454            password_history: None,
3455            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3456            deleted_date: None,
3457            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3458            archived_date: None,
3459            data: None,
3460        };
3461
3462        let view: CipherView = key_store.decrypt(&cipher).unwrap();
3463
3464        // Should have 2 successful attachments
3465        assert!(view.attachments.is_some());
3466        let successes = view.attachments.as_ref().unwrap();
3467        assert_eq!(successes.len(), 2);
3468        assert_eq!(successes[0].id, Some("valid-attachment".to_string()));
3469        assert_eq!(successes[1].id, Some("valid-attachment-2".to_string()));
3470
3471        // Should have 1 failed attachment
3472        assert!(view.attachment_decryption_failures.is_some());
3473        let failures = view.attachment_decryption_failures.as_ref().unwrap();
3474        assert_eq!(failures.len(), 1);
3475        assert_eq!(failures[0].id, Some("corrupted-attachment".to_string()));
3476        assert_eq!(failures[0].file_name, None);
3477    }
3478
3479    #[test]
3480    fn test_decrypt_cipher_list_view_passport() {
3481        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3482            SymmetricKeyAlgorithm::Aes256CbcHmac,
3483        ));
3484
3485        let cipher_view = CipherView {
3486            r#type: CipherType::Passport,
3487            passport: Some(passport::PassportView {
3488                given_name: Some("Jane".to_string()),
3489                surname: Some("Doe".to_string()),
3490                passport_number: Some("P12345678".to_string()),
3491                ..Default::default()
3492            }),
3493            login: None,
3494            ..generate_cipher()
3495        };
3496
3497        let cipher: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3498        let list_view: CipherListView = key_store.decrypt(&cipher).unwrap();
3499
3500        assert_eq!(list_view.r#type, CipherListViewType::Passport);
3501        assert_eq!(list_view.subtitle, "Jane Doe");
3502        assert_eq!(
3503            list_view.copyable_fields,
3504            vec![
3505                CopyableCipherFields::PassportGivenName,
3506                CopyableCipherFields::PassportSurname,
3507                CopyableCipherFields::PassportPassportNumber,
3508            ]
3509        );
3510    }
3511
3512    #[test]
3513    fn test_decrypt_cipher_list_view_drivers_license() {
3514        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3515            SymmetricKeyAlgorithm::Aes256CbcHmac,
3516        ));
3517
3518        let cipher_view = CipherView {
3519            r#type: CipherType::DriversLicense,
3520            drivers_license: Some(drivers_license::DriversLicenseView {
3521                first_name: Some("John".to_string()),
3522                last_name: Some("Doe".to_string()),
3523                license_number: Some("DL-987654".to_string()),
3524                ..Default::default()
3525            }),
3526            login: None,
3527            ..generate_cipher()
3528        };
3529
3530        let cipher: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3531        let list_view: CipherListView = key_store.decrypt(&cipher).unwrap();
3532
3533        assert_eq!(list_view.r#type, CipherListViewType::DriversLicense);
3534        assert_eq!(list_view.subtitle, "John Doe");
3535        assert_eq!(
3536            list_view.copyable_fields,
3537            vec![
3538                CopyableCipherFields::DriversLicenseFirstName,
3539                CopyableCipherFields::DriversLicenseLastName,
3540                CopyableCipherFields::DriversLicenseLicenseNumber,
3541            ]
3542        );
3543    }
3544
3545    #[test]
3546    fn test_cipher_view_encrypt_decrypt_passport() {
3547        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3548            SymmetricKeyAlgorithm::Aes256CbcHmac,
3549        ));
3550
3551        let passport = passport::PassportView {
3552            given_name: Some("Jane".to_string()),
3553            surname: Some("Doe".to_string()),
3554            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
3555            sex: Some("F".to_string()),
3556            birth_place: Some("New York".to_string()),
3557            nationality: Some("American".to_string()),
3558            issuing_country: Some("US".to_string()),
3559            passport_number: Some("P12345678".to_string()),
3560            passport_type: Some("P".to_string()),
3561            national_identification_number: Some("123-45-6789".to_string()),
3562            issuing_authority: Some("US State Department".to_string()),
3563            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
3564            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
3565        };
3566
3567        let cipher_view = CipherView {
3568            r#type: CipherType::Passport,
3569            passport: Some(passport.clone()),
3570            login: None,
3571            ..generate_cipher()
3572        };
3573
3574        let encrypted: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3575        let decrypted: CipherView = key_store.decrypt(&encrypted).unwrap();
3576
3577        assert_eq!(decrypted.r#type, CipherType::Passport);
3578        assert_eq!(decrypted.passport, Some(passport));
3579        assert!(decrypted.login.is_none());
3580    }
3581
3582    #[test]
3583    fn test_cipher_view_encrypt_decrypt_drivers_license() {
3584        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3585            SymmetricKeyAlgorithm::Aes256CbcHmac,
3586        ));
3587
3588        let dl = drivers_license::DriversLicenseView {
3589            first_name: Some("John".to_string()),
3590            middle_name: Some("Michael".to_string()),
3591            last_name: Some("Doe".to_string()),
3592            date_of_birth: chrono::NaiveDate::from_ymd_opt(1985, 6, 15),
3593            license_number: Some("DL-987654".to_string()),
3594            issuing_country: Some("US".to_string()),
3595            issuing_state: Some("NY".to_string()),
3596            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
3597            expiration_date: chrono::NaiveDate::from_ymd_opt(2028, 1, 1),
3598            issuing_authority: Some("NY DMV".to_string()),
3599            license_class: Some("D".to_string()),
3600        };
3601
3602        let cipher_view = CipherView {
3603            r#type: CipherType::DriversLicense,
3604            drivers_license: Some(dl.clone()),
3605            login: None,
3606            ..generate_cipher()
3607        };
3608
3609        let encrypted: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3610        let decrypted: CipherView = key_store.decrypt(&encrypted).unwrap();
3611
3612        assert_eq!(decrypted.r#type, CipherType::DriversLicense);
3613        assert_eq!(decrypted.drivers_license, Some(dl));
3614        assert!(decrypted.login.is_none());
3615    }
3616
3617    #[test]
3618    fn test_mini_response_model_view_password_defaults_to_true() {
3619        use chrono::Utc;
3620
3621        // CipherMiniResponseModel does not include view_password from the API,
3622        // so when merge_with_cipher is called with None, it should default to true
3623        let mini_response = CipherMiniResponseModel {
3624            id: Some(TEST_UUID.parse().unwrap()),
3625            name: Some(TEST_CIPHER_NAME.to_string()),
3626            r#type: Some(bitwarden_api_api::models::CipherType::Login),
3627            creation_date: Some(Utc::now().to_rfc3339()),
3628            revision_date: Some(Utc::now().to_rfc3339()),
3629            ..Default::default()
3630        };
3631
3632        let cipher = mini_response.merge_with_cipher(None).unwrap();
3633        assert!(
3634            cipher.view_password,
3635            "view_password should default to true for CipherMiniResponseModel"
3636        );
3637
3638        // CipherMiniDetailsResponseModel should also default to true
3639        let mini_details_response = CipherMiniDetailsResponseModel {
3640            id: Some(TEST_UUID.parse().unwrap()),
3641            name: Some(TEST_CIPHER_NAME.to_string()),
3642            r#type: Some(bitwarden_api_api::models::CipherType::Login),
3643            creation_date: Some(Utc::now().to_rfc3339()),
3644            revision_date: Some(Utc::now().to_rfc3339()),
3645            ..Default::default()
3646        };
3647
3648        let cipher = mini_details_response.merge_with_cipher(None).unwrap();
3649        assert!(
3650            cipher.view_password,
3651            "view_password should default to true for CipherMiniDetailsResponseModel"
3652        );
3653    }
3654
3655    // ---------- Cipher Decryptable dispatch + CipherView::to_list_view ----------
3656
3657    mod cipher_decrypt_dispatch {
3658        use bitwarden_crypto::KeyStore;
3659
3660        use super::*;
3661        use crate::{
3662            BankAccountView, CardView, DriversLicenseView, IdentityView, PassportView,
3663            SecureNoteType, SecureNoteView, SshKeyView, cipher::blob::encrypt_blob_cipher,
3664        };
3665
3666        fn make_key_store() -> KeyStore<KeySlotIds> {
3667            create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3668                SymmetricKeyAlgorithm::Aes256CbcHmac,
3669            ))
3670        }
3671
3672        /// Encrypt a view through the legacy field-level path.
3673        fn encrypt_legacy(view: CipherView, key_store: &KeyStore<KeySlotIds>) -> Cipher {
3674            key_store.encrypt(EncryptMode::Legacy(view)).unwrap()
3675        }
3676
3677        /// Encrypt a view through the blob path.
3678        fn encrypt_blob(mut view: CipherView, key_store: &KeyStore<KeySlotIds>) -> Cipher {
3679            let mut ctx = key_store.context_mut();
3680            encrypt_blob_cipher(&mut view, &mut ctx).unwrap()
3681        }
3682
3683        fn base_login_view() -> CipherView {
3684            let mut view = generate_cipher();
3685            view.name = "Test Login".to_string();
3686            view.login = Some(LoginView {
3687                username: Some("[email protected]".to_string()),
3688                password: Some("hunter2".to_string()),
3689                password_revision_date: None,
3690                uris: None,
3691                totp: Some("otpauth://totp/test?secret=SECRET".to_string()),
3692                autofill_on_page_load: None,
3693                fido2_credentials: None,
3694            });
3695            view
3696        }
3697
3698        /// Blob cipher → `CipherView` dispatch works end-to-end.
3699        #[test]
3700        fn dispatches_blob_to_cipher_view() {
3701            let key_store = make_key_store();
3702            let cipher = encrypt_blob(base_login_view(), &key_store);
3703
3704            let view: CipherView = key_store.decrypt(&cipher).unwrap();
3705
3706            assert_eq!(view.name, "Test Login");
3707            let login = view.login.expect("blob decrypt should restore login");
3708            assert_eq!(login.username.as_deref(), Some("[email protected]"));
3709            assert_eq!(login.password.as_deref(), Some("hunter2"));
3710        }
3711
3712        /// Legacy cipher → `CipherView` dispatch works via both default (lenient) and strict
3713        /// paths.
3714        #[test]
3715        fn dispatches_legacy_to_cipher_view() {
3716            let key_store = make_key_store();
3717
3718            // Default (lenient) path on `Cipher`.
3719            let cipher = encrypt_legacy(base_login_view(), &key_store);
3720            let view: CipherView = key_store.decrypt(&cipher).unwrap();
3721            assert_eq!(view.name, "Test Login");
3722            assert_eq!(
3723                view.login.unwrap().username.as_deref(),
3724                Some("[email protected]"),
3725            );
3726
3727            // Strict path via `StrictDecrypt<Cipher>`.
3728            let cipher = encrypt_legacy(base_login_view(), &key_store);
3729            let view: CipherView = key_store.decrypt(&StrictDecrypt(cipher)).unwrap();
3730            assert_eq!(view.name, "Test Login");
3731            assert_eq!(
3732                view.login.unwrap().username.as_deref(),
3733                Some("[email protected]"),
3734            );
3735        }
3736
3737        /// Blob ciphers of every type produce a well-formed `CipherListView`.
3738        ///
3739        /// Exercises each arm of [`CipherView::to_list_view`]: subtitle derivation,
3740        /// list-view type discriminant, and `copyable_fields`.
3741        #[test]
3742        fn blob_to_list_view_per_type() {
3743            let key_store = make_key_store();
3744
3745            // --- Login ---
3746            {
3747                let list_view = decrypt_blob_list_view(&key_store, base_login_view());
3748                assert_eq!(list_view.name, "Test Login");
3749                assert_eq!(list_view.subtitle, "[email protected]");
3750                assert!(matches!(list_view.r#type, CipherListViewType::Login(_)));
3751                assert!(
3752                    list_view
3753                        .copyable_fields
3754                        .contains(&CopyableCipherFields::LoginUsername)
3755                );
3756                assert!(
3757                    list_view
3758                        .copyable_fields
3759                        .contains(&CopyableCipherFields::LoginPassword)
3760                );
3761                assert!(
3762                    list_view
3763                        .copyable_fields
3764                        .contains(&CopyableCipherFields::LoginTotp)
3765                );
3766            }
3767
3768            // --- Card ---
3769            {
3770                let mut view = generate_cipher();
3771                view.r#type = CipherType::Card;
3772                view.login = None;
3773                view.name = "My Card".to_string();
3774                view.card = Some(CardView {
3775                    cardholder_name: Some("John Doe".to_string()),
3776                    exp_month: Some("12".to_string()),
3777                    exp_year: Some("2030".to_string()),
3778                    code: Some("123".to_string()),
3779                    brand: Some("Visa".to_string()),
3780                    number: Some("4111111111111111".to_string()),
3781                });
3782                let list_view = decrypt_blob_list_view(&key_store, view);
3783                assert_eq!(list_view.name, "My Card");
3784                assert!(list_view.subtitle.contains("Visa"));
3785                assert!(list_view.subtitle.contains("1111"));
3786                match &list_view.r#type {
3787                    CipherListViewType::Card(card) => {
3788                        assert_eq!(card.brand.as_deref(), Some("Visa"))
3789                    }
3790                    other => panic!("expected Card, got {other:?}"),
3791                }
3792                assert!(
3793                    list_view
3794                        .copyable_fields
3795                        .contains(&CopyableCipherFields::CardNumber)
3796                );
3797                assert!(
3798                    list_view
3799                        .copyable_fields
3800                        .contains(&CopyableCipherFields::CardSecurityCode)
3801                );
3802            }
3803
3804            // --- Identity ---
3805            {
3806                let mut view = generate_cipher();
3807                view.r#type = CipherType::Identity;
3808                view.login = None;
3809                view.name = "My Identity".to_string();
3810                view.identity = Some(IdentityView {
3811                    title: None,
3812                    first_name: Some("Jane".to_string()),
3813                    middle_name: None,
3814                    last_name: Some("Doe".to_string()),
3815                    address1: Some("123 Main St".to_string()),
3816                    address2: None,
3817                    address3: None,
3818                    city: None,
3819                    state: None,
3820                    postal_code: None,
3821                    country: None,
3822                    company: None,
3823                    email: Some("[email protected]".to_string()),
3824                    phone: None,
3825                    ssn: None,
3826                    username: None,
3827                    passport_number: None,
3828                    license_number: None,
3829                });
3830                let list_view = decrypt_blob_list_view(&key_store, view);
3831                assert_eq!(list_view.name, "My Identity");
3832                assert!(list_view.subtitle.contains("Jane"));
3833                assert!(list_view.subtitle.contains("Doe"));
3834                assert!(matches!(list_view.r#type, CipherListViewType::Identity));
3835                assert!(
3836                    list_view
3837                        .copyable_fields
3838                        .contains(&CopyableCipherFields::IdentityEmail)
3839                );
3840                assert!(
3841                    list_view
3842                        .copyable_fields
3843                        .contains(&CopyableCipherFields::IdentityAddress)
3844                );
3845            }
3846
3847            // --- SecureNote ---
3848            {
3849                let mut view = generate_cipher();
3850                view.r#type = CipherType::SecureNote;
3851                view.login = None;
3852                view.name = "My Note".to_string();
3853                view.notes = Some("secret".to_string());
3854                view.secure_note = Some(SecureNoteView {
3855                    r#type: SecureNoteType::Generic,
3856                });
3857                let list_view = decrypt_blob_list_view(&key_store, view);
3858                assert_eq!(list_view.name, "My Note");
3859                assert_eq!(list_view.subtitle, "");
3860                assert!(matches!(list_view.r#type, CipherListViewType::SecureNote));
3861                assert!(
3862                    list_view
3863                        .copyable_fields
3864                        .contains(&CopyableCipherFields::SecureNotes)
3865                );
3866            }
3867
3868            // --- SshKey ---
3869            {
3870                let mut view = generate_cipher();
3871                view.r#type = CipherType::SshKey;
3872                view.login = None;
3873                view.name = "My SSH".to_string();
3874                view.ssh_key = Some(SshKeyView {
3875                    private_key: "-----BEGIN PRIVATE KEY-----".to_string(),
3876                    public_key: "ssh-ed25519 AAAA".to_string(),
3877                    fingerprint: "SHA256:abcdef".to_string(),
3878                });
3879                let list_view = decrypt_blob_list_view(&key_store, view);
3880                assert_eq!(list_view.name, "My SSH");
3881                assert_eq!(list_view.subtitle, "SHA256:abcdef");
3882                assert!(matches!(list_view.r#type, CipherListViewType::SshKey));
3883                assert!(
3884                    list_view
3885                        .copyable_fields
3886                        .contains(&CopyableCipherFields::SshKey)
3887                );
3888            }
3889
3890            // --- BankAccount ---
3891            {
3892                let mut view = generate_cipher();
3893                view.r#type = CipherType::BankAccount;
3894                view.login = None;
3895                view.name = "My Bank Account".to_string();
3896                view.bank_account = Some(BankAccountView {
3897                    bank_name: Some("Some Bank".to_string()),
3898                    name_on_account: Some("Jane Doe".to_string()),
3899                    account_number: Some("123456".to_string()),
3900                    routing_number: Some("111000025".to_string()),
3901                    branch_number: Some("001".to_string()),
3902                    pin: Some("4321".to_string()),
3903                    swift_code: Some("ABCDEF12".to_string()),
3904                    iban: Some("DE89370400440532013000".to_string()),
3905                    ..Default::default()
3906                });
3907                let list_view = decrypt_blob_list_view(&key_store, view);
3908                assert_eq!(list_view.name, "My Bank Account");
3909                assert_eq!(list_view.subtitle, "Some Bank");
3910                assert_eq!(
3911                    list_view.r#type,
3912                    CipherListViewType::BankAccount(BankAccountListView {
3913                        account_number: Some("123456".to_string()),
3914                        account_type: None,
3915                    })
3916                );
3917                assert_eq!(
3918                    list_view.copyable_fields,
3919                    vec![
3920                        CopyableCipherFields::BankAccountNameOnAccount,
3921                        CopyableCipherFields::BankAccountAccountNumber,
3922                        CopyableCipherFields::BankAccountRoutingNumber,
3923                        CopyableCipherFields::BankAccountBranchNumber,
3924                        CopyableCipherFields::BankAccountPin,
3925                        CopyableCipherFields::BankAccountIban,
3926                        CopyableCipherFields::BankAccountSwift,
3927                    ]
3928                );
3929            }
3930        }
3931
3932        /// A fully-populated `CipherView` for every [`CipherType`], so that every
3933        /// presence-gated `copyable_fields` branch fires.
3934        ///
3935        /// Every optional field that influences `copyable_fields` is set; a new copyable
3936        /// field added to one decryption path but not the other will change one path's
3937        /// output and trip [`copyable_fields_parity_between_legacy_and_blob`].
3938        fn fully_populated_views() -> Vec<(&'static str, CipherView)> {
3939            let with_type = |r#type: CipherType, f: &dyn Fn(&mut CipherView)| {
3940                let mut view = generate_cipher();
3941                view.r#type = r#type;
3942                view.login = None;
3943                f(&mut view);
3944                view
3945            };
3946
3947            vec![
3948                ("Login", base_login_view()),
3949                (
3950                    "Card",
3951                    with_type(CipherType::Card, &|v| {
3952                        v.card = Some(CardView {
3953                            cardholder_name: Some("Jane Doe".to_string()),
3954                            exp_month: Some("12".to_string()),
3955                            exp_year: Some("2030".to_string()),
3956                            code: Some("123".to_string()),
3957                            brand: Some("Visa".to_string()),
3958                            number: Some("4111111111111111".to_string()),
3959                        });
3960                    }),
3961                ),
3962                (
3963                    "Identity",
3964                    with_type(CipherType::Identity, &|v| {
3965                        v.identity = Some(IdentityView {
3966                            title: Some("Mx".to_string()),
3967                            first_name: Some("Jane".to_string()),
3968                            middle_name: Some("Q".to_string()),
3969                            last_name: Some("Doe".to_string()),
3970                            address1: Some("1 Main St".to_string()),
3971                            address2: Some("Apt 2".to_string()),
3972                            address3: Some("Floor 3".to_string()),
3973                            city: Some("Anytown".to_string()),
3974                            state: Some("CA".to_string()),
3975                            postal_code: Some("90210".to_string()),
3976                            country: Some("US".to_string()),
3977                            company: Some("Acme".to_string()),
3978                            email: Some("[email protected]".to_string()),
3979                            phone: Some("555-0100".to_string()),
3980                            ssn: Some("000-00-0000".to_string()),
3981                            username: Some("jane".to_string()),
3982                            passport_number: Some("X1234567".to_string()),
3983                            license_number: Some("D1234567".to_string()),
3984                        });
3985                    }),
3986                ),
3987                (
3988                    "SecureNote",
3989                    with_type(CipherType::SecureNote, &|v| {
3990                        v.notes = Some("a secret note".to_string());
3991                        v.secure_note = Some(SecureNoteView {
3992                            r#type: SecureNoteType::Generic,
3993                        });
3994                    }),
3995                ),
3996                (
3997                    "SshKey",
3998                    with_type(CipherType::SshKey, &|v| {
3999                        v.ssh_key = Some(SshKeyView {
4000                            private_key: "private".to_string(),
4001                            public_key: "public".to_string(),
4002                            fingerprint: "SHA256:abc".to_string(),
4003                        });
4004                    }),
4005                ),
4006                (
4007                    "BankAccount",
4008                    with_type(CipherType::BankAccount, &|v| {
4009                        v.bank_account = Some(BankAccountView {
4010                            bank_name: Some("Some Bank".to_string()),
4011                            name_on_account: Some("Jane Doe".to_string()),
4012                            account_type: Some("Checking".to_string()),
4013                            account_number: Some("123456".to_string()),
4014                            routing_number: Some("111000025".to_string()),
4015                            branch_number: Some("001".to_string()),
4016                            pin: Some("4321".to_string()),
4017                            swift_code: Some("ABCDEF12".to_string()),
4018                            iban: Some("DE89370400440532013000".to_string()),
4019                            bank_contact_phone: Some("555-0199".to_string()),
4020                        });
4021                    }),
4022                ),
4023                (
4024                    "DriversLicense",
4025                    with_type(CipherType::DriversLicense, &|v| {
4026                        v.drivers_license = Some(DriversLicenseView {
4027                            first_name: Some("Jane".to_string()),
4028                            middle_name: Some("Q".to_string()),
4029                            last_name: Some("Doe".to_string()),
4030                            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
4031                            license_number: Some("D1234567".to_string()),
4032                            issuing_country: Some("US".to_string()),
4033                            issuing_state: Some("CA".to_string()),
4034                            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4035                            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
4036                            issuing_authority: Some("DMV".to_string()),
4037                            license_class: Some("C".to_string()),
4038                        });
4039                    }),
4040                ),
4041                (
4042                    "Passport",
4043                    with_type(CipherType::Passport, &|v| {
4044                        v.passport = Some(PassportView {
4045                            surname: Some("Doe".to_string()),
4046                            given_name: Some("Jane".to_string()),
4047                            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
4048                            sex: Some("F".to_string()),
4049                            birth_place: Some("Anytown".to_string()),
4050                            nationality: Some("US".to_string()),
4051                            issuing_country: Some("US".to_string()),
4052                            passport_number: Some("X1234567".to_string()),
4053                            passport_type: Some("P".to_string()),
4054                            national_identification_number: Some("000-00-0000".to_string()),
4055                            issuing_authority: Some("State Dept".to_string()),
4056                            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4057                            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
4058                        });
4059                    }),
4060                ),
4061            ]
4062        }
4063
4064        /// The legacy field-level path and the blob path independently derive
4065        /// `copyable_fields` — legacy from `Option<EncString>` presence on the encrypted
4066        /// kind, blob from `Option<String>` presence on the decrypted view. They must
4067        /// agree for identical input, or the same cipher renders differently depending on
4068        /// its storage format. This guards every type against drift without hardcoding the
4069        /// expected set per type.
4070        #[test]
4071        fn copyable_fields_parity_between_legacy_and_blob() {
4072            let key_store = make_key_store();
4073
4074            for (label, view) in fully_populated_views() {
4075                let legacy: CipherListView = key_store
4076                    .decrypt(&encrypt_legacy(view.clone(), &key_store))
4077                    .unwrap();
4078                let blob = decrypt_blob_list_view(&key_store, view);
4079
4080                assert_eq!(
4081                    legacy.copyable_fields, blob.copyable_fields,
4082                    "copyable_fields diverged between legacy and blob paths for {label}",
4083                );
4084            }
4085        }
4086
4087        /// Blob path unseals plaintext TOTP; the projection re-encrypts it under the
4088        /// cipher key so [`CipherListView::get_totp_key`] (which decrypts on demand)
4089        /// still returns the original plaintext.
4090        #[test]
4091        fn login_list_view_preserves_totp_round_trip() {
4092            let key_store = make_key_store();
4093            let list_view = decrypt_blob_list_view(&key_store, base_login_view());
4094
4095            match &list_view.r#type {
4096                CipherListViewType::Login(login) => assert!(login.totp.is_some()),
4097                other => panic!("expected Login, got {other:?}"),
4098            }
4099            let totp = list_view.get_totp_key(&mut key_store.context()).unwrap();
4100            assert_eq!(totp.as_deref(), Some("otpauth://totp/test?secret=SECRET"));
4101        }
4102
4103        /// `decrypt_list` handles a slice containing both blob and legacy ciphers
4104        #[test]
4105        fn mixed_batch_decrypt_list() {
4106            let key_store = make_key_store();
4107            let blob = encrypt_blob(base_login_view(), &key_store);
4108            let legacy = encrypt_legacy(base_login_view(), &key_store);
4109
4110            let ciphers = vec![blob, legacy];
4111            let views: Vec<CipherListView> = key_store.decrypt_list(&ciphers).unwrap();
4112
4113            assert_eq!(views.len(), 2);
4114            for v in &views {
4115                assert_eq!(v.name, "Test Login");
4116                assert_eq!(v.subtitle, "[email protected]");
4117            }
4118        }
4119
4120        fn decrypt_blob_list_view(
4121            key_store: &KeyStore<KeySlotIds>,
4122            view: CipherView,
4123        ) -> CipherListView {
4124            let cipher = encrypt_blob(view, key_store);
4125            key_store.decrypt(&cipher).unwrap()
4126        }
4127
4128        /// Three attachments whose `EncString`s are sealed under an unrelated
4129        /// key, so they fail to decrypt under any cipher key. The middle one has
4130        /// no wrapped key, marking it an "old" (v1) attachment.
4131        fn failing_attachments() -> Vec<attachment::Attachment> {
4132            let wrong = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4133                SymmetricKeyAlgorithm::Aes256CbcHmac,
4134            ));
4135            let mut ctx = wrong.context();
4136            let mut enc = |s: &str| s.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap();
4137            vec![
4138                attachment::Attachment {
4139                    id: Some("a1".to_string()),
4140                    url: None,
4141                    size: None,
4142                    size_name: None,
4143                    file_name: Some(enc("a1.txt")),
4144                    key: Some(enc("k1")),
4145                },
4146                attachment::Attachment {
4147                    id: Some("a2-old".to_string()),
4148                    url: None,
4149                    size: None,
4150                    size_name: None,
4151                    file_name: Some(enc("a2.txt")),
4152                    key: None,
4153                },
4154                attachment::Attachment {
4155                    id: Some("a3".to_string()),
4156                    url: None,
4157                    size: None,
4158                    size_name: None,
4159                    file_name: Some(enc("a3.txt")),
4160                    key: Some(enc("k3")),
4161                },
4162            ]
4163        }
4164
4165        /// Attachment metrics must agree across paths even when attachments fail
4166        /// to decrypt. The legacy path counts the encrypted server model directly;
4167        /// the blob path routes failures into `attachment_decryption_failures`, so
4168        /// the projection must count those too — otherwise a corrupt attachment
4169        /// makes the same cipher report a different `attachments` count and
4170        /// `has_old_attachments` flag depending on its storage format.
4171        #[test]
4172        fn attachment_metrics_parity_with_failing_attachments() {
4173            let key_store = make_key_store();
4174
4175            let mut legacy = encrypt_legacy(base_login_view(), &key_store);
4176            legacy.attachments = Some(failing_attachments());
4177            let legacy_list: CipherListView = key_store.decrypt(&legacy).unwrap();
4178
4179            let mut blob = encrypt_blob(base_login_view(), &key_store);
4180            blob.attachments = Some(failing_attachments());
4181            let blob_list: CipherListView = key_store.decrypt(&blob).unwrap();
4182
4183            assert_eq!(legacy_list.attachments, 3);
4184            assert!(legacy_list.has_old_attachments);
4185            assert_eq!(blob_list.attachments, legacy_list.attachments);
4186            assert_eq!(
4187                blob_list.has_old_attachments,
4188                legacy_list.has_old_attachments,
4189            );
4190        }
4191    }
4192
4193    // ---------- EncryptMode ----------
4194
4195    mod encrypt_mode {
4196        use bitwarden_crypto::{IdentifyKey, KeyStore};
4197
4198        use super::*;
4199
4200        fn make_key_store() -> KeyStore<KeySlotIds> {
4201            create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4202                SymmetricKeyAlgorithm::Aes256CbcHmac,
4203            ))
4204        }
4205
4206        fn base_login_view() -> CipherView {
4207            let mut view = generate_cipher();
4208            view.name = "Round Trip".to_string();
4209            view.login = Some(LoginView {
4210                username: Some("[email protected]".to_string()),
4211                password: Some("hunter2".to_string()),
4212                password_revision_date: None,
4213                uris: None,
4214                totp: None,
4215                autofill_on_page_load: None,
4216                fido2_credentials: None,
4217            });
4218            view
4219        }
4220
4221        /// Blob variant produces a blob-shaped cipher: sealed `data`, placeholder
4222        /// `name`, and every per-type sensitive field cleared.
4223        #[test]
4224        fn blob_variant_produces_blob_shaped_cipher() {
4225            let key_store = make_key_store();
4226            let mode = EncryptMode::Blob(base_login_view());
4227
4228            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4229
4230            assert!(try_parse_blob(&cipher).is_some());
4231            assert!(cipher.data.is_some());
4232            assert!(cipher.login.is_none());
4233            assert!(cipher.card.is_none());
4234            assert!(cipher.identity.is_none());
4235            assert!(cipher.secure_note.is_none());
4236            assert!(cipher.ssh_key.is_none());
4237            assert!(cipher.bank_account.is_none());
4238            assert!(cipher.fields.is_none());
4239            assert!(cipher.password_history.is_none());
4240            assert!(cipher.notes.is_none());
4241        }
4242
4243        /// Legacy variant produces a legacy-shaped cipher: `data` empty, and the
4244        /// matching per-type field populated.
4245        #[test]
4246        fn legacy_variant_produces_legacy_shaped_cipher() {
4247            let key_store = make_key_store();
4248            let mode = EncryptMode::Legacy(base_login_view());
4249
4250            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4251
4252            assert!(try_parse_blob(&cipher).is_none());
4253            assert!(cipher.data.is_none());
4254            assert!(cipher.login.is_some());
4255        }
4256
4257        /// Blob variant round-trips through decryption
4258        #[test]
4259        fn blob_variant_round_trips_through_decrypt() {
4260            let key_store = make_key_store();
4261            let original = base_login_view();
4262            let mode = EncryptMode::Blob(original.clone());
4263
4264            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4265            let restored: CipherView = key_store.decrypt(&cipher).unwrap();
4266
4267            assert_eq!(restored.name, original.name);
4268            let login = restored.login.expect("round-trip should restore login");
4269            assert_eq!(login.username, original.login.as_ref().unwrap().username);
4270            assert_eq!(login.password, original.login.as_ref().unwrap().password);
4271        }
4272
4273        /// `key_identifier` must delegate to the inner view so `encrypt_list`
4274        /// selects the correct scope key.
4275        #[test]
4276        fn key_identifier_delegates_to_inner_view() {
4277            let view = base_login_view();
4278            let expected = view.key_identifier();
4279            let mode = EncryptMode::Blob(view);
4280            assert_eq!(mode.key_identifier(), expected);
4281        }
4282
4283        /// A mixed-batch `encrypt_list` preserves input order and produces a
4284        /// cipher shaped per-variant.
4285        #[test]
4286        fn mixed_batch_encrypt_list_preserves_per_item_shape() {
4287            let key_store = make_key_store();
4288            let mut legacy_view = base_login_view();
4289            legacy_view.name = "Legacy".to_string();
4290            let mut blob_view = base_login_view();
4291            blob_view.name = "Blob".to_string();
4292
4293            let modes = vec![
4294                EncryptMode::Legacy(legacy_view),
4295                EncryptMode::Blob(blob_view),
4296            ];
4297            let ciphers: Vec<Cipher> = key_store.encrypt_list(&modes).unwrap();
4298
4299            assert_eq!(ciphers.len(), 2);
4300            assert!(
4301                try_parse_blob(&ciphers[0]).is_none(),
4302                "first item should be legacy"
4303            );
4304            assert!(
4305                try_parse_blob(&ciphers[1]).is_some(),
4306                "second item should be blob"
4307            );
4308            assert!(ciphers[0].login.is_some());
4309            assert!(ciphers[1].login.is_none());
4310        }
4311    }
4312}