Skip to main content

bitwarden_vault/cipher/
cipher.rs

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