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