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