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