Skip to main content

bitwarden_vault/cipher/
cipher.rs

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