Skip to main content

bitwarden_vault/cipher/
cipher.rs

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