Skip to main content

bitwarden_vault/cipher/
cipher.rs

1use bitwarden_api_api::models::{
2    CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel,
3    CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel,
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{
7    ApiError, MissingFieldError, OrganizationId, UserId,
8    key_management::{KeySlotIds, MINIMUM_ENFORCE_ICON_URI_HASH_VERSION, SymmetricKeySlotId},
9    require,
10};
11use bitwarden_crypto::{
12    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
13    PrimitiveEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm,
14};
15use bitwarden_error::bitwarden_error;
16use bitwarden_state::repository::RepositoryError;
17use bitwarden_uuid::uuid_newtype;
18use chrono::{DateTime, SecondsFormat, Utc};
19use serde::{Deserialize, Serialize};
20use serde_repr::{Deserialize_repr, Serialize_repr};
21use thiserror::Error;
22#[cfg(feature = "wasm")]
23use tsify::Tsify;
24#[cfg(feature = "wasm")]
25use wasm_bindgen::prelude::wasm_bindgen;
26
27use super::{
28    attachment, bank_account,
29    bank_account::BankAccountListView,
30    blob::{decrypt_blob_cipher, encrypt_blob_cipher_with_wrapping_key, try_parse_blob},
31    card,
32    card::CardListView,
33    cipher_permissions::CipherPermissions,
34    drivers_license, field, identity,
35    local_data::{LocalData, LocalDataView},
36    login::{LoginListView, LoginUri, LoginUriView},
37    passport, secure_note, ssh_key,
38};
39use crate::{
40    DecryptError, EncryptError, Fido2CredentialFullView, Fido2CredentialView, FieldView, FolderId,
41    Login, LoginView, VaultParseError,
42    password_history::{self, MAX_PASSWORD_HISTORY_ENTRIES},
43};
44
45uuid_newtype!(pub CipherId);
46
47#[allow(missing_docs)]
48#[bitwarden_error(flat)]
49#[derive(Debug, Error)]
50pub enum CipherError {
51    #[error(transparent)]
52    MissingField(#[from] MissingFieldError),
53    #[error(transparent)]
54    Crypto(#[from] CryptoError),
55    #[error(transparent)]
56    Decrypt(#[from] DecryptError),
57    #[error(transparent)]
58    Encrypt(#[from] EncryptError),
59    #[error(
60        "This cipher contains attachments without keys. Those attachments will need to be reuploaded to complete the operation"
61    )]
62    AttachmentsWithoutKeys,
63    #[error("This cipher cannot be moved to the specified organization")]
64    OrganizationAlreadySet,
65    #[error(transparent)]
66    Repository(#[from] RepositoryError),
67    #[error(transparent)]
68    Chrono(#[from] chrono::ParseError),
69    #[error(transparent)]
70    SerdeJson(#[from] serde_json::Error),
71    #[error(transparent)]
72    Api(#[from] ApiError),
73}
74
75/// Helper trait for operations on cipher types.
76pub(super) trait CipherKind {
77    /// Returns the item's subtitle.
78    fn decrypt_subtitle(
79        &self,
80        ctx: &mut KeyStoreContext<KeySlotIds>,
81        key: SymmetricKeySlotId,
82    ) -> Result<String, CryptoError>;
83
84    /// Returns a list of populated fields for the cipher.
85    fn get_copyable_fields(&self, cipher: Option<&Cipher>) -> Vec<CopyableCipherFields>;
86}
87
88#[allow(missing_docs)]
89#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
90#[repr(u8)]
91#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
92#[cfg_attr(feature = "wasm", wasm_bindgen)]
93pub enum CipherType {
94    Login = 1,
95    SecureNote = 2,
96    Card = 3,
97    Identity = 4,
98    SshKey = 5,
99    BankAccount = 6,
100    DriversLicense = 7,
101    Passport = 8,
102}
103
104#[allow(missing_docs)]
105#[derive(Clone, Copy, Default, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
106#[repr(u8)]
107#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
108#[cfg_attr(feature = "wasm", wasm_bindgen)]
109pub enum CipherRepromptType {
110    #[default]
111    None = 0,
112    Password = 1,
113}
114
115#[allow(missing_docs)]
116#[derive(Serialize, Deserialize, Debug, Clone)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
119#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
120pub struct EncryptionContext {
121    /// The Id of the user that encrypted the cipher. It should always represent a UserId, even for
122    /// Organization-owned ciphers
123    pub encrypted_for: UserId,
124    /// Hex-encoded id of the key the cipher's fields are wrapped under - the organization key for
125    /// Organization-owned ciphers, otherwise the user key - captured at the time the cipher was
126    /// encrypted. The server uses it to reject writes made under a wrong key.
127    #[serde(default)]
128    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
129    #[cfg_attr(feature = "wasm", tsify(optional))]
130    pub encrypted_by_key_id: Option<String>,
131    pub cipher: Cipher,
132}
133
134impl TryFrom<EncryptionContext> for CipherWithIdRequestModel {
135    type Error = CipherError;
136    fn try_from(
137        EncryptionContext {
138            cipher,
139            encrypted_for,
140            encrypted_by_key_id,
141        }: EncryptionContext,
142    ) -> Result<Self, Self::Error> {
143        Ok(Self {
144            id: require!(cipher.id).into(),
145            encrypted_for: Some(encrypted_for.into()),
146            encrypted_by_key_id,
147            r#type: Some(cipher.r#type.into()),
148            organization_id: cipher.organization_id.map(|o| o.to_string()),
149            is_organization_cipher: None,
150            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
151            favorite: cipher.favorite.into(),
152            reprompt: Some(cipher.reprompt.into()),
153            key: cipher.key.map(|k| k.to_string()),
154            name: cipher.name.as_ref().map(ToString::to_string),
155            notes: cipher.notes.map(|n| n.to_string()),
156            fields: Some(
157                cipher
158                    .fields
159                    .into_iter()
160                    .flatten()
161                    .map(Into::into)
162                    .collect(),
163            ),
164            password_history: Some(
165                cipher
166                    .password_history
167                    .into_iter()
168                    .flatten()
169                    .map(Into::into)
170                    .collect(),
171            ),
172            attachments: None,
173            attachments2: Some(
174                cipher
175                    .attachments
176                    .into_iter()
177                    .flatten()
178                    .filter_map(|a| {
179                        a.id.map(|id| {
180                            (
181                                id,
182                                bitwarden_api_api::models::CipherAttachmentModel {
183                                    file_name: a.file_name.map(|n| n.to_string()),
184                                    key: a.key.map(|k| k.to_string()),
185                                },
186                            )
187                        })
188                    })
189                    .collect(),
190            ),
191            login: cipher.login.map(|l| Box::new(l.into())),
192            card: cipher.card.map(|c| Box::new(c.into())),
193            identity: cipher.identity.map(|i| Box::new(i.into())),
194            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
195            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
196            bank_account: cipher.bank_account.map(|b| Box::new(b.into())),
197            drivers_license: cipher.drivers_license.map(|d| Box::new(d.into())),
198            passport: cipher.passport.map(|p| Box::new(p.into())),
199            data: cipher.data,
200            last_known_revision_date: Some(
201                cipher
202                    .revision_date
203                    .to_rfc3339_opts(SecondsFormat::Millis, true),
204            ),
205            archived_date: cipher
206                .archived_date
207                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
208        })
209    }
210}
211
212impl From<EncryptionContext> for CipherRequestModel {
213    fn from(
214        EncryptionContext {
215            cipher,
216            encrypted_for,
217            encrypted_by_key_id,
218        }: EncryptionContext,
219    ) -> Self {
220        Self {
221            encrypted_for: Some(encrypted_for.into()),
222            encrypted_by_key_id,
223            r#type: Some(cipher.r#type.into()),
224            organization_id: cipher.organization_id.map(|o| o.to_string()),
225            is_organization_cipher: None,
226            folder_id: cipher.folder_id.as_ref().map(ToString::to_string),
227            favorite: cipher.favorite.into(),
228            reprompt: Some(cipher.reprompt.into()),
229            key: cipher.key.map(|k| k.to_string()),
230            name: cipher.name.as_ref().map(ToString::to_string),
231            notes: cipher.notes.map(|n| n.to_string()),
232            fields: Some(
233                cipher
234                    .fields
235                    .into_iter()
236                    .flatten()
237                    .map(Into::into)
238                    .collect(),
239            ),
240            password_history: Some(
241                cipher
242                    .password_history
243                    .into_iter()
244                    .flatten()
245                    .map(Into::into)
246                    .collect(),
247            ),
248            attachments: None,
249            attachments2: Some(
250                cipher
251                    .attachments
252                    .into_iter()
253                    .flatten()
254                    .filter_map(|a| {
255                        a.id.map(|id| {
256                            (
257                                id,
258                                bitwarden_api_api::models::CipherAttachmentModel {
259                                    file_name: a.file_name.map(|n| n.to_string()),
260                                    key: a.key.map(|k| k.to_string()),
261                                },
262                            )
263                        })
264                    })
265                    .collect(),
266            ),
267            login: cipher.login.map(|l| Box::new(l.into())),
268            card: cipher.card.map(|c| Box::new(c.into())),
269            identity: cipher.identity.map(|i| Box::new(i.into())),
270            secure_note: cipher.secure_note.map(|s| Box::new(s.into())),
271            ssh_key: cipher.ssh_key.map(|s| Box::new(s.into())),
272            bank_account: cipher.bank_account.map(|b| Box::new(b.into())),
273            drivers_license: cipher.drivers_license.map(|d| Box::new(d.into())),
274            passport: cipher.passport.map(|p| Box::new(p.into())),
275            data: cipher.data,
276            last_known_revision_date: Some(
277                cipher
278                    .revision_date
279                    .to_rfc3339_opts(SecondsFormat::Millis, true),
280            ),
281            archived_date: cipher
282                .archived_date
283                .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
284        }
285    }
286}
287
288/// Encrypted cipher, as stored and synced.
289///
290/// # Encryption-format internal fields
291///
292/// Only metadata fields (ids, `key`, `type`, flags, permissions, dates, `attachments`,
293/// `local_data`) are stable to read. Fields marked "encryption-format internal" depend on the
294/// encryption format and must not be read or filtered on by clients:
295/// - Legacy field-level format: each field is individually encrypted.
296/// - Blob format: these fields are `None`; all sensitive data is sealed in `data`.
297///
298/// Decrypt to [`CipherView`] or [`CipherListView`] to inspect item contents (e.g. whether a
299/// login has a TOTP or passkey).
300#[allow(missing_docs)]
301#[derive(Serialize, Deserialize, Debug, Clone)]
302#[serde(rename_all = "camelCase", deny_unknown_fields)]
303#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
304#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
305pub struct Cipher {
306    pub id: Option<CipherId>,
307    pub organization_id: Option<OrganizationId>,
308    pub folder_id: Option<FolderId>,
309    pub collection_ids: Vec<CollectionId>,
310    /// Per-cipher key, wrapped under the owning user or organization key, that protects the
311    /// cipher's fields. Always set on ciphers the SDK encrypts; `None` only on legacy
312    /// ciphers that predates per-cipher keys.
313    pub key: Option<EncString>,
314
315    /// Encrypted item name. `None` for blob-encrypted ciphers, where the name lives inside
316    /// the sealed `data` blob; required on the legacy field-level format.
317    /// Encryption-format internal, not public API. See [`Cipher`].
318    pub name: Option<EncString>,
319    /// Encryption-format internal, not public API. See [`Cipher`].
320    pub notes: Option<EncString>,
321
322    pub r#type: CipherType,
323    /// Encryption-format internal, not public API. See [`Cipher`].
324    pub login: Option<Login>,
325    /// Encryption-format internal, not public API. See [`Cipher`].
326    pub identity: Option<identity::Identity>,
327    /// Encryption-format internal, not public API. See [`Cipher`].
328    pub card: Option<card::Card>,
329    /// Encryption-format internal, not public API. See [`Cipher`].
330    pub secure_note: Option<secure_note::SecureNote>,
331    /// Encryption-format internal, not public API. See [`Cipher`].
332    pub ssh_key: Option<ssh_key::SshKey>,
333    /// Encryption-format internal, not public API. See [`Cipher`].
334    pub bank_account: Option<bank_account::BankAccount>,
335    /// Encryption-format internal, not public API. See [`Cipher`].
336    pub drivers_license: Option<drivers_license::DriversLicense>,
337    /// Encryption-format internal, not public API. See [`Cipher`].
338    pub passport: Option<passport::Passport>,
339
340    pub favorite: bool,
341    pub reprompt: CipherRepromptType,
342    pub organization_use_totp: bool,
343    pub edit: bool,
344    pub permissions: Option<CipherPermissions>,
345    pub view_password: bool,
346    pub local_data: Option<LocalData>,
347
348    pub attachments: Option<Vec<attachment::Attachment>>,
349    /// Encryption-format internal, not public API. See [`Cipher`].
350    pub fields: Option<Vec<field::Field>>,
351    /// Encryption-format internal, not public API. See [`Cipher`].
352    pub password_history: Option<Vec<password_history::PasswordHistory>>,
353
354    pub creation_date: DateTime<Utc>,
355    pub deleted_date: Option<DateTime<Utc>>,
356    pub revision_date: DateTime<Utc>,
357    pub archived_date: Option<DateTime<Utc>>,
358    /// Sealed blob holding all sensitive data in the blob format; `None` on the legacy format.
359    /// Opaque, not public API. See [`Cipher`].
360    pub data: Option<String>,
361
362    /// Raw JSON envelope for a server-restricted (PAM-gated) cipher: only contains a sub-set of
363    /// non sensitive fields, all other fields are withheld by the server. Its presence marks the
364    /// cipher restricted; the decrypt path parses only these allowlisted fields and produces a
365    /// view with `partial = true`, never reading the secret payloads.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub partial_data: Option<String>,
368}
369
370/// Represents the result of re-wrapping a cipher key, which can be needed when changing the
371/// ownership of a cipher or rotating keys.
372pub enum CipherKeyRewrapError {
373    NoCipherKey,
374    DecryptionFailure,
375    EncryptionFailure,
376}
377
378impl Cipher {
379    /// Re-wraps the encrypted cipher-key. This should be done when moving the cipher to a new
380    /// ownership (user to org), or when rotating the owning key. This mutates the cipher's key
381    /// field if successful, otherwise returns an error. Data stays encrypted the same way and
382    /// does not need to be re-uploaded to the server.
383    pub fn rewrap_cipher_key(
384        &mut self,
385        old_key: SymmetricKeySlotId,
386        new_key: SymmetricKeySlotId,
387        ctx: &mut KeyStoreContext<KeySlotIds>,
388    ) -> Result<(), CipherKeyRewrapError> {
389        let new_cipher_key = self
390            .key
391            .as_ref()
392            .ok_or(CipherKeyRewrapError::NoCipherKey)
393            .and_then(|wrapped_cipher_key| {
394                ctx.unwrap_symmetric_key(old_key, wrapped_cipher_key)
395                    .map_err(|_| CipherKeyRewrapError::DecryptionFailure)
396            })
397            .and_then(|cipher_key| {
398                ctx.wrap_symmetric_key(new_key, cipher_key)
399                    .map_err(|_| CipherKeyRewrapError::EncryptionFailure)
400            })?;
401        self.key = Some(new_cipher_key);
402        Ok(())
403    }
404
405    /// Returns `true` if this cipher's sensitive data is stored in the sealed-blob format.
406    pub fn is_blob_encrypted(&self) -> bool {
407        try_parse_blob(self).is_some()
408    }
409}
410
411bitwarden_state::register_repository_item!(CipherId => Cipher, "Cipher");
412
413impl TryFrom<Cipher> for CipherRequestModel {
414    type Error = CryptoError;
415
416    /// Structural mapping from an encrypted [`Cipher`] to the API's expected
417    /// [`CipherRequestModel`]. No crypto — all encryption happened upstream in
418    /// `CipherView::encrypt_composite`. Callers are responsible for setting
419    /// `encrypted_for` and `encrypted_by_key_id` after the conversion.
420    ///
421    /// Fails with [`CryptoError::MissingField`] if any attachment has no `id`
422    fn try_from(c: Cipher) -> Result<Self, Self::Error> {
423        let attachments2 = c
424            .attachments
425            .map(|list| {
426                list.into_iter()
427                    .map(|a| {
428                        let id = a.id.clone().ok_or(CryptoError::MissingField("id"))?;
429                        Ok::<_, CryptoError>((id, a.into()))
430                    })
431                    .collect::<Result<_, _>>()
432            })
433            .transpose()?;
434
435        Ok(CipherRequestModel {
436            encrypted_for: None,
437            encrypted_by_key_id: None,
438            r#type: Some(c.r#type.into()),
439            organization_id: c.organization_id.map(|id| id.to_string()),
440            is_organization_cipher: None,
441            folder_id: c.folder_id.map(|id| id.to_string()),
442            favorite: Some(c.favorite),
443            reprompt: Some(c.reprompt.into()),
444            key: c.key.map(|k| k.to_string()),
445            name: c.name.as_ref().map(ToString::to_string),
446            notes: c.notes.map(|n| n.to_string()),
447            login: c.login.map(|v| Box::new(v.into())),
448            card: c.card.map(|v| Box::new(v.into())),
449            identity: c.identity.map(|v| Box::new(v.into())),
450            secure_note: c.secure_note.map(|v| Box::new(v.into())),
451            ssh_key: c.ssh_key.map(|v| Box::new(v.into())),
452            bank_account: c.bank_account.map(|v| Box::new(v.into())),
453            drivers_license: c.drivers_license.map(|v| Box::new(v.into())),
454            passport: c.passport.map(|v| Box::new(v.into())),
455            fields: c.fields.map(|f| f.into_iter().map(Into::into).collect()),
456            password_history: c
457                .password_history
458                .map(|h| h.into_iter().map(Into::into).collect()),
459            attachments: None,
460            attachments2,
461            last_known_revision_date: Some(
462                c.revision_date.to_rfc3339_opts(SecondsFormat::Secs, true),
463            ),
464            archived_date: c.archived_date.map(|d| d.to_rfc3339()),
465            data: c.data,
466        })
467    }
468}
469
470#[allow(missing_docs)]
471#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
472#[serde(rename_all = "camelCase", deny_unknown_fields)]
473#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
474#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
475pub struct CipherView {
476    pub id: Option<CipherId>,
477    pub organization_id: Option<OrganizationId>,
478    pub folder_id: Option<FolderId>,
479    pub collection_ids: Vec<CollectionId>,
480
481    #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey | undefined"))]
482    pub key: Option<SymmetricCryptoKey>,
483
484    pub name: String,
485    pub notes: Option<String>,
486
487    pub r#type: CipherType,
488    pub login: Option<LoginView>,
489    pub identity: Option<identity::IdentityView>,
490    pub card: Option<card::CardView>,
491    pub secure_note: Option<secure_note::SecureNoteView>,
492    pub ssh_key: Option<ssh_key::SshKeyView>,
493    pub bank_account: Option<bank_account::BankAccountView>,
494    pub drivers_license: Option<drivers_license::DriversLicenseView>,
495    pub passport: Option<passport::PassportView>,
496
497    pub favorite: bool,
498    pub reprompt: CipherRepromptType,
499    pub organization_use_totp: bool,
500    pub edit: bool,
501    pub permissions: Option<CipherPermissions>,
502    pub view_password: bool,
503    pub local_data: Option<LocalDataView>,
504
505    pub attachments: Option<Vec<attachment::AttachmentView>>,
506    /// Attachments that failed to decrypt. Only present when there are decryption failures.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub attachment_decryption_failures: Option<Vec<attachment::AttachmentView>>,
509    pub fields: Option<Vec<field::FieldView>>,
510    pub password_history: Option<Vec<password_history::PasswordHistoryView>>,
511    pub creation_date: DateTime<Utc>,
512    pub deleted_date: Option<DateTime<Utc>>,
513    pub revision_date: DateTime<Utc>,
514    pub archived_date: Option<DateTime<Utc>>,
515
516    /// True when this view was produced from a server-restricted (PAM-gated) cipher. Only a
517    /// sub-set of fields are populated; every secret field is absent. See
518    /// [`Cipher::partial_data`].
519    /// Such a view is fail-closed against re-encryption: passing it to any encrypt path returns
520    /// [`bitwarden_crypto::CryptoError::EncryptRestrictedView`] rather than silently stripping
521    /// secrets.
522    #[serde(default)]
523    pub partial: bool,
524}
525
526#[allow(missing_docs)]
527#[derive(Serialize, Deserialize, Debug, PartialEq)]
528#[serde(rename_all = "camelCase", deny_unknown_fields)]
529#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
530#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
531pub enum CipherListViewType {
532    Login(LoginListView),
533    SecureNote,
534    Card(CardListView),
535    Identity,
536    SshKey,
537    BankAccount(BankAccountListView),
538    Passport,
539    DriversLicense,
540}
541
542/// Available fields on a cipher and can be copied from a the list view in the UI.
543#[derive(Serialize, Deserialize, Debug, PartialEq)]
544#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
545#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
546pub enum CopyableCipherFields {
547    LoginUsername,
548    LoginPassword,
549    LoginTotp,
550    CardNumber,
551    CardSecurityCode,
552    IdentityUsername,
553    IdentityEmail,
554    IdentityPhone,
555    IdentityAddress,
556    SshKey,
557    SecureNotes,
558    BankAccountNameOnAccount,
559    BankAccountAccountNumber,
560    BankAccountRoutingNumber,
561    BankAccountBranchNumber,
562    BankAccountPin,
563    BankAccountIban,
564    BankAccountSwift,
565    PassportGivenName,
566    PassportSurname,
567    PassportPassportNumber,
568    PassportNationalIdentificationNumber,
569    DriversLicenseFirstName,
570    DriversLicenseMiddleName,
571    DriversLicenseLastName,
572    DriversLicenseLicenseNumber,
573}
574
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#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
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#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
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#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
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#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
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 PartialCipher for CipherMiniResponseModel {
2213    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
2214        let cipher = cipher.as_ref();
2215        Ok(Cipher {
2216            partial_data: self.partial_data,
2217            id: self.id.map(CipherId::new),
2218            organization_id: self.organization_id.map(OrganizationId::new),
2219            key: EncString::try_from_optional(self.key)?,
2220            name: EncString::try_from_optional(self.name)?,
2221            notes: EncString::try_from_optional(self.notes)?,
2222            r#type: require!(self.r#type).try_into()?,
2223            login: self.login.map(|l| (*l).try_into()).transpose()?,
2224            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
2225            card: self.card.map(|c| (*c).try_into()).transpose()?,
2226            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
2227            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
2228            bank_account: self.bank_account.map(|b| (*b).try_into()).transpose()?,
2229            drivers_license: self.drivers_license.map(|d| (*d).try_into()).transpose()?,
2230            passport: self.passport.map(|p| (*p).try_into()).transpose()?,
2231            reprompt: self
2232                .reprompt
2233                .map(|r| r.try_into())
2234                .transpose()?
2235                .unwrap_or(CipherRepromptType::None),
2236            organization_use_totp: self.organization_use_totp.unwrap_or(true),
2237            attachments: self
2238                .attachments
2239                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
2240                .transpose()?,
2241            fields: self
2242                .fields
2243                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
2244                .transpose()?,
2245            password_history: self
2246                .password_history
2247                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
2248                .transpose()?,
2249            creation_date: require!(self.creation_date)
2250                .parse()
2251                .map_err(Into::<VaultParseError>::into)?,
2252            deleted_date: self
2253                .deleted_date
2254                .map(|d| d.parse())
2255                .transpose()
2256                .map_err(Into::<VaultParseError>::into)?,
2257            revision_date: require!(self.revision_date)
2258                .parse()
2259                .map_err(Into::<VaultParseError>::into)?,
2260            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
2261            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
2262            favorite: cipher.map_or(Default::default(), |c| c.favorite),
2263            edit: cipher.map_or(Default::default(), |c| c.edit),
2264            permissions: cipher.map_or(Default::default(), |c| c.permissions),
2265            view_password: cipher.is_none_or(|c| c.view_password),
2266            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
2267            data: self.data,
2268            collection_ids: cipher.map_or(Default::default(), |c| c.collection_ids.clone()),
2269        })
2270    }
2271}
2272
2273impl PartialCipher for CipherMiniDetailsResponseModel {
2274    fn merge_with_cipher(self, cipher: Option<Cipher>) -> Result<Cipher, VaultParseError> {
2275        let cipher = cipher.as_ref();
2276        Ok(Cipher {
2277            partial_data: self.partial_data,
2278            id: self.id.map(CipherId::new),
2279            organization_id: self.organization_id.map(OrganizationId::new),
2280            key: EncString::try_from_optional(self.key)?,
2281            name: EncString::try_from_optional(self.name)?,
2282            notes: EncString::try_from_optional(self.notes)?,
2283            r#type: require!(self.r#type).try_into()?,
2284            login: self.login.map(|l| (*l).try_into()).transpose()?,
2285            identity: self.identity.map(|i| (*i).try_into()).transpose()?,
2286            card: self.card.map(|c| (*c).try_into()).transpose()?,
2287            secure_note: self.secure_note.map(|s| (*s).try_into()).transpose()?,
2288            ssh_key: self.ssh_key.map(|s| (*s).try_into()).transpose()?,
2289            bank_account: self.bank_account.map(|b| (*b).try_into()).transpose()?,
2290            drivers_license: self.drivers_license.map(|d| (*d).try_into()).transpose()?,
2291            passport: self.passport.map(|p| (*p).try_into()).transpose()?,
2292            reprompt: self
2293                .reprompt
2294                .map(|r| r.try_into())
2295                .transpose()?
2296                .unwrap_or(CipherRepromptType::None),
2297            organization_use_totp: self.organization_use_totp.unwrap_or(true),
2298            attachments: self
2299                .attachments
2300                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
2301                .transpose()?,
2302            fields: self
2303                .fields
2304                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
2305                .transpose()?,
2306            password_history: self
2307                .password_history
2308                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
2309                .transpose()?,
2310            creation_date: require!(self.creation_date)
2311                .parse()
2312                .map_err(Into::<VaultParseError>::into)?,
2313            deleted_date: self
2314                .deleted_date
2315                .map(|d| d.parse())
2316                .transpose()
2317                .map_err(Into::<VaultParseError>::into)?,
2318            revision_date: require!(self.revision_date)
2319                .parse()
2320                .map_err(Into::<VaultParseError>::into)?,
2321            collection_ids: self
2322                .collection_ids
2323                .into_iter()
2324                .flatten()
2325                .map(CollectionId::new)
2326                .collect(),
2327            archived_date: cipher.map_or(Default::default(), |c| c.archived_date),
2328            folder_id: cipher.map_or(Default::default(), |c| c.folder_id),
2329            favorite: cipher.map_or(Default::default(), |c| c.favorite),
2330            edit: cipher.map_or(Default::default(), |c| c.edit),
2331            permissions: cipher.map_or(Default::default(), |c| c.permissions),
2332            view_password: cipher.is_none_or(|c: &Cipher| c.view_password),
2333            data: cipher.map_or(Default::default(), |c| c.data.clone()),
2334            local_data: cipher.map_or(Default::default(), |c| c.local_data.clone()),
2335        })
2336    }
2337}
2338
2339#[cfg(test)]
2340mod tests {
2341
2342    use attachment::AttachmentView;
2343    use bitwarden_core::key_management::{
2344        create_test_crypto_with_user_and_org_key, create_test_crypto_with_user_key,
2345    };
2346    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
2347
2348    use super::*;
2349    use crate::{Fido2Credential, PasswordHistoryView, login::Fido2CredentialListView};
2350
2351    // Test constants for encrypted strings
2352    const TEST_ENC_STRING_1: &str = "2.xzDCDWqRBpHm42EilUvyVw==|nIrWV3l/EeTbWTnAznrK0Q==|sUj8ol2OTgvvTvD86a9i9XUP58hmtCEBqhck7xT5YNk=";
2353    const TEST_ENC_STRING_2: &str = "2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=";
2354    const TEST_ENC_STRING_3: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
2355    const TEST_ENC_STRING_4: &str = "2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=";
2356    const TEST_ENC_STRING_5: &str = "2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=";
2357    const TEST_CIPHER_NAME: &str = "2.d3rzo0P8rxV9Hs1m1BmAjw==|JOwna6i0zs+K7ZghwrZRuw==|SJqKreLag1ID+g6H1OdmQr0T5zTrVWKzD6hGy3fDqB0=";
2358    const TEST_UUID: &str = "fd411a1a-fec8-4070-985d-0e6560860e69";
2359
2360    /// Fixed org id + key for the restricted-cipher test vectors (partials are always org-owned).
2361    const RESTRICTED_ORG_UUID: &str = "3cf0d3ba-3ded-4bf3-a51c-b03fd9ac6e07";
2362    const RESTRICTED_ORG_KEY_B64: &str =
2363        "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==";
2364
2365    /// `partial_data` vectors: EncStrings encrypted once under [`RESTRICTED_ORG_KEY_B64`] and
2366    /// pinned, so decrypt runs against fixed ciphertext and the format can't silently break.
2367    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}]}"#;
2368    /// [`RESTRICTED_LOGIN_ENVELOPE`] plus an extra `password` field the allowlist must drop.
2369    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="}"#;
2370    const RESTRICTED_SECURE_NOTE_ENVELOPE: &str = r#"{"name":"2.EwVjyRAgPmUvRpyT68lbjQ==|73O9id1+DZevAB3K+2fXnA==|OoJhN3p9UgjQ4yk55OUBZ4nYQMlFcf9wTHPxrOPhQVI="}"#;
2371    const RESTRICTED_CARD_ENVELOPE: &str = r#"{"name":"2.HF21EOZVqF3eyeZtEgxaCg==|zuChVgXPqxipFE6zOBUBXQ==|gxyvw0+gMf5Grxk8EAhpLCBeXdA0kvea2maJmpLIUIw="}"#;
2372
2373    /// Key store for the restricted-cipher vectors: [`RESTRICTED_ORG_KEY_B64`] under
2374    /// [`RESTRICTED_ORG_UUID`] (the user key is unused — restricted decrypt is org-keyed).
2375    fn restricted_test_key_store() -> (OrganizationId, KeyStore<KeySlotIds>) {
2376        let org: OrganizationId = RESTRICTED_ORG_UUID.parse().unwrap();
2377        let org_key: SymmetricCryptoKey = RESTRICTED_ORG_KEY_B64.to_string().try_into().unwrap();
2378        let key_store = create_test_crypto_with_user_and_org_key(
2379            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
2380            org,
2381            org_key,
2382        );
2383        (org, key_store)
2384    }
2385
2386    fn generate_cipher() -> CipherView {
2387        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
2388        CipherView {
2389            partial: false,
2390            r#type: CipherType::Login,
2391            login: Some(LoginView {
2392                username: Some("test_username".to_string()),
2393                password: Some("test_password".to_string()),
2394                password_revision_date: None,
2395                uris: None,
2396                totp: None,
2397                autofill_on_page_load: None,
2398                fido2_credentials: None,
2399            }),
2400            id: Some(test_id),
2401            organization_id: None,
2402            folder_id: None,
2403            collection_ids: vec![],
2404            key: None,
2405            name: "My test login".to_string(),
2406            notes: None,
2407            identity: None,
2408            card: None,
2409            secure_note: None,
2410            ssh_key: None,
2411            bank_account: None,
2412            drivers_license: None,
2413            passport: None,
2414            favorite: false,
2415            reprompt: CipherRepromptType::None,
2416            organization_use_totp: true,
2417            edit: true,
2418            permissions: None,
2419            view_password: true,
2420            local_data: None,
2421            attachments: None,
2422            attachment_decryption_failures: None,
2423            fields: None,
2424            password_history: None,
2425            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2426            deleted_date: None,
2427            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2428            archived_date: None,
2429        }
2430    }
2431
2432    fn generate_fido2(
2433        ctx: &mut KeyStoreContext<KeySlotIds>,
2434        key: SymmetricKeySlotId,
2435    ) -> Fido2Credential {
2436        Fido2Credential {
2437            credential_id: "123".to_string().encrypt(ctx, key).unwrap(),
2438            key_type: "public-key".to_string().encrypt(ctx, key).unwrap(),
2439            key_algorithm: "ECDSA".to_string().encrypt(ctx, key).unwrap(),
2440            key_curve: "P-256".to_string().encrypt(ctx, key).unwrap(),
2441            key_value: "123".to_string().encrypt(ctx, key).unwrap(),
2442            rp_id: "123".to_string().encrypt(ctx, key).unwrap(),
2443            user_handle: None,
2444            user_name: None,
2445            counter: "123".to_string().encrypt(ctx, key).unwrap(),
2446            rp_name: None,
2447            user_display_name: None,
2448            discoverable: "true".to_string().encrypt(ctx, key).unwrap(),
2449            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
2450        }
2451    }
2452
2453    fn generate_fido2_view() -> Fido2CredentialView {
2454        Fido2CredentialView {
2455            credential_id: "123".to_string(),
2456            key_type: "public-key".to_string(),
2457            key_algorithm: "ECDSA".to_string(),
2458            key_curve: "P-256".to_string(),
2459            key_value: "123".to_string(),
2460            rp_id: "123".to_string(),
2461            user_handle: None,
2462            user_name: None,
2463            counter: "123".to_string(),
2464            rp_name: None,
2465            user_display_name: None,
2466            discoverable: "true".to_string(),
2467            creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
2468        }
2469    }
2470
2471    /// Builds a server-restricted (PAM-gated) cipher: no top-level secret fields, only the
2472    /// `partial_data` envelope. Org-owned, since partials always are.
2473    fn restricted_cipher(
2474        organization_id: OrganizationId,
2475        r#type: CipherType,
2476        partial_data: String,
2477    ) -> Cipher {
2478        Cipher {
2479            partial_data: Some(partial_data),
2480            id: Some(TEST_UUID.parse().unwrap()),
2481            organization_id: Some(organization_id),
2482            folder_id: None,
2483            collection_ids: vec![],
2484            key: None,
2485            name: None,
2486            notes: None,
2487            r#type,
2488            login: None,
2489            identity: None,
2490            card: None,
2491            secure_note: None,
2492            ssh_key: None,
2493            bank_account: None,
2494            drivers_license: None,
2495            passport: None,
2496            favorite: false,
2497            reprompt: CipherRepromptType::None,
2498            organization_use_totp: false,
2499            edit: true,
2500            permissions: None,
2501            view_password: true,
2502            local_data: None,
2503            attachments: None,
2504            fields: None,
2505            password_history: None,
2506            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2507            deleted_date: None,
2508            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2509            archived_date: None,
2510            data: None,
2511        }
2512    }
2513
2514    #[test]
2515    fn test_decrypt_restricted_cipher_list_view_login() {
2516        let (org, key_store) = restricted_test_key_store();
2517        let cipher = restricted_cipher(
2518            org,
2519            CipherType::Login,
2520            RESTRICTED_LOGIN_ENVELOPE.to_string(),
2521        );
2522        let view: CipherListView = key_store.decrypt(&cipher).unwrap();
2523
2524        assert!(view.partial);
2525        assert_eq!(view.name, "Restricted Name".to_string());
2526        assert!(view.subtitle.is_empty());
2527        assert!(view.copyable_fields.is_empty());
2528        assert_eq!(view.attachments, 0);
2529        match view.r#type {
2530            CipherListViewType::Login(login) => {
2531                assert_eq!(
2532                    login.uris.unwrap()[0].uri.as_deref(),
2533                    Some("https://example.com")
2534                );
2535                assert_eq!(login.username, None);
2536                assert_eq!(login.totp, None);
2537                assert!(!login.has_fido2);
2538            }
2539            other => panic!("expected Login list view, got {other:?}"),
2540        }
2541    }
2542
2543    #[test]
2544    fn test_decrypt_restricted_cipher_view_login_omits_secrets() {
2545        let (org, key_store) = restricted_test_key_store();
2546        // A rogue/over-sharing server also stuffs an encrypted password into the envelope; the
2547        // allowlist (`RestrictedCipherData`) must ignore anything outside name + uris.
2548        let cipher = restricted_cipher(
2549            org,
2550            CipherType::Login,
2551            RESTRICTED_LOGIN_ENVELOPE_WITH_PASSWORD.to_string(),
2552        );
2553        let view: CipherView = key_store.decrypt(&cipher).unwrap();
2554
2555        assert!(view.partial);
2556        assert_eq!(view.name, "Restricted Name".to_string());
2557        let login = view
2558            .login
2559            .expect("login populated so the view can render a domain");
2560        assert_eq!(
2561            login.uris.unwrap()[0].uri.as_deref(),
2562            Some("https://example.com")
2563        );
2564        assert_eq!(login.username, None);
2565        assert_eq!(login.password, None);
2566        assert_eq!(login.totp, None);
2567        assert_eq!(view.notes, None);
2568        assert!(view.card.is_none());
2569    }
2570
2571    #[test]
2572    fn test_decrypt_restricted_non_login_name_only() {
2573        let (org, key_store) = restricted_test_key_store();
2574        let cipher = restricted_cipher(
2575            org,
2576            CipherType::SecureNote,
2577            RESTRICTED_SECURE_NOTE_ENVELOPE.to_string(),
2578        );
2579
2580        let list: CipherListView = key_store.decrypt(&cipher).unwrap();
2581        assert!(list.partial);
2582        assert_eq!(list.name, "Gated Note".to_string());
2583        assert_eq!(list.r#type, CipherListViewType::SecureNote);
2584
2585        let view: CipherView = key_store.decrypt(&cipher).unwrap();
2586        assert!(view.partial);
2587        assert_eq!(view.name, "Gated Note".to_string());
2588        assert!(view.login.is_none());
2589        assert!(view.secure_note.is_none());
2590    }
2591
2592    #[test]
2593    fn test_decrypt_restricted_malformed_envelope_fails_closed() {
2594        let (org, key_store) = restricted_test_key_store();
2595        // A malformed envelope must not un-gate the row: it stays partial, just nameless.
2596        let cipher = restricted_cipher(org, CipherType::Login, "not valid json".to_string());
2597
2598        let list: CipherListView = key_store.decrypt(&cipher).unwrap();
2599        assert!(list.partial);
2600        assert!(list.name.is_empty());
2601
2602        let view: CipherView = key_store.decrypt(&cipher).unwrap();
2603        assert!(view.partial);
2604        assert!(view.name.is_empty());
2605
2606        // A malformed envelope is a wire-shape problem, not a crypto failure: lenient even in
2607        // strict mode.
2608        let strict_view: CipherView = key_store.decrypt(&StrictDecrypt(cipher.clone())).unwrap();
2609        assert!(strict_view.partial);
2610        assert!(strict_view.name.is_empty());
2611    }
2612
2613    #[test]
2614    fn test_decrypt_restricted_works_in_strict_mode() {
2615        let (org, key_store) = restricted_test_key_store();
2616        // A Card with no card payload would hit `MissingField("card")` under strict decrypt —
2617        // the restricted branch must short-circuit before that.
2618        let cipher = restricted_cipher(org, CipherType::Card, RESTRICTED_CARD_ENVELOPE.to_string());
2619
2620        let list: CipherListView = key_store.decrypt(&StrictDecrypt(cipher.clone())).unwrap();
2621        assert!(list.partial);
2622        assert_eq!(list.name, "Restricted Card".to_string());
2623        assert_eq!(
2624            list.r#type,
2625            CipherListViewType::Card(CardListView { brand: None })
2626        );
2627
2628        let view: CipherView = key_store.decrypt(&StrictDecrypt(cipher)).unwrap();
2629        assert!(view.partial);
2630        assert!(view.card.is_none());
2631    }
2632
2633    #[test]
2634    fn test_decrypt_restricted_strict_propagates_field_decrypt_error() {
2635        let (org, key_store) = restricted_test_key_store();
2636        // Encrypt a name under an UNRELATED key so it is present in the envelope but cannot be
2637        // decrypted with the organization key.
2638        let wrong_key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2639            SymmetricKeyAlgorithm::Aes256CbcHmac,
2640        ));
2641        let bad_name = "boom"
2642            .to_string()
2643            .encrypt(&mut wrong_key_store.context(), SymmetricKeySlotId::User)
2644            .unwrap();
2645        let envelope = serde_json::json!({ "name": bad_name }).to_string();
2646        let cipher = restricted_cipher(org, CipherType::Login, envelope);
2647
2648        // Lenient degrades to an empty name, still gated.
2649        let view: CipherView = key_store.decrypt(&cipher).unwrap();
2650        assert!(view.partial);
2651        assert!(view.name.is_empty());
2652
2653        // Strict surfaces the present-but-undecryptable field as an error (both view and list).
2654        let view_result: Result<CipherView, CryptoError> =
2655            key_store.decrypt(&StrictDecrypt(cipher.clone()));
2656        assert!(view_result.is_err());
2657        let list_result: Result<CipherListView, CryptoError> =
2658            key_store.decrypt(&StrictDecrypt(cipher));
2659        assert!(list_result.is_err());
2660    }
2661
2662    #[test]
2663    fn test_decrypt_restricted_non_org_cipher_fails_closed() {
2664        // A personal cipher carrying `partial_data` must fail closed, never take the restricted
2665        // path.
2666        let (org, key_store) = restricted_test_key_store();
2667        let cipher = Cipher {
2668            organization_id: None,
2669            ..restricted_cipher(
2670                org,
2671                CipherType::SecureNote,
2672                RESTRICTED_SECURE_NOTE_ENVELOPE.to_string(),
2673            )
2674        };
2675
2676        let view_result: Result<CipherView, CryptoError> = key_store.decrypt(&cipher);
2677        assert!(matches!(
2678            view_result.unwrap_err(),
2679            CryptoError::RestrictedCipherRequiresOrganization
2680        ));
2681
2682        let list_result: Result<CipherListView, CryptoError> = key_store.decrypt(&cipher);
2683        assert!(matches!(
2684            list_result.unwrap_err(),
2685            CryptoError::RestrictedCipherRequiresOrganization
2686        ));
2687    }
2688
2689    #[test]
2690    fn test_decrypt_cipher_list_view() {
2691        let key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
2692        let key_store = create_test_crypto_with_user_key(key);
2693
2694        let cipher = Cipher {
2695            partial_data: None,
2696            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
2697            organization_id: None,
2698            folder_id: None,
2699            collection_ids: vec![],
2700            key: None,
2701            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
2702            notes: None,
2703            r#type: CipherType::Login,
2704            login: Some(Login {
2705                username: Some("2.EBNGgnaMHeO/kYnI3A0jiA==|9YXlrgABP71ebZ5umurCJQ==|GDk5jxiqTYaU7e2AStCFGX+a1kgCIk8j0NEli7Jn0L4=".parse().unwrap()),
2706                password: Some("2.M7ZJ7EuFDXCq66gDTIyRIg==|B1V+jroo6+m/dpHx6g8DxA==|PIXPBCwyJ1ady36a7jbcLg346pm/7N/06W4UZxc1TUo=".parse().unwrap()),
2707                password_revision_date: None,
2708                uris: None,
2709                totp: Some("2.hqdioUAc81FsKQmO1XuLQg==|oDRdsJrQjoFu9NrFVy8tcJBAFKBx95gHaXZnWdXbKpsxWnOr2sKipIG43pKKUFuq|3gKZMiboceIB5SLVOULKg2iuyu6xzos22dfJbvx0EHk=".parse().unwrap()),
2710                autofill_on_page_load: None,
2711                fido2_credentials: Some(vec![generate_fido2(&mut key_store.context(), SymmetricKeySlotId::User)]),
2712            }),
2713            identity: None,
2714            card: None,
2715            secure_note: None,
2716            ssh_key: None,
2717            bank_account: None,
2718            drivers_license: None,
2719            passport: None,
2720            favorite: false,
2721            reprompt: CipherRepromptType::None,
2722            organization_use_totp: false,
2723            edit: true,
2724            permissions: Some(CipherPermissions {
2725                delete: false,
2726                restore: false
2727            }),
2728            view_password: true,
2729            local_data: None,
2730            attachments: None,
2731            fields: None,
2732            password_history: None,
2733            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2734            deleted_date: None,
2735            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
2736            archived_date: None,
2737            data: None,
2738        };
2739
2740        let view: CipherListView = key_store.decrypt(&cipher).unwrap();
2741
2742        assert_eq!(
2743            view,
2744            CipherListView {
2745                partial: false,
2746                id: cipher.id,
2747                organization_id: cipher.organization_id,
2748                folder_id: cipher.folder_id,
2749                collection_ids: cipher.collection_ids,
2750                name: "My test login".to_string(),
2751                subtitle: "test_username".to_string(),
2752                r#type: CipherListViewType::Login(LoginListView {
2753                    fido2_credentials: Some(vec![Fido2CredentialListView {
2754                        credential_id: "123".to_string(),
2755                        rp_id: "123".to_string(),
2756                        user_handle: None,
2757                        user_name: None,
2758                        user_display_name: None,
2759                        counter: "123".to_string(),
2760                    }]),
2761                    has_fido2: true,
2762                    username: Some("test_username".to_string()),
2763                    totp: cipher.login.as_ref().unwrap().totp.as_ref().map(|t| t
2764                        .decrypt(&mut key_store.context(), SymmetricKeySlotId::User)
2765                        .unwrap()),
2766                    uris: None,
2767                }),
2768                favorite: cipher.favorite,
2769                reprompt: cipher.reprompt,
2770                organization_use_totp: cipher.organization_use_totp,
2771                edit: cipher.edit,
2772                permissions: cipher.permissions,
2773                view_password: cipher.view_password,
2774                attachments: 0,
2775                has_old_attachments: false,
2776                creation_date: cipher.creation_date,
2777                deleted_date: cipher.deleted_date,
2778                revision_date: cipher.revision_date,
2779                copyable_fields: vec![
2780                    CopyableCipherFields::LoginUsername,
2781                    CopyableCipherFields::LoginPassword,
2782                    CopyableCipherFields::LoginTotp
2783                ],
2784                local_data: None,
2785                archived_date: cipher.archived_date,
2786                #[cfg(feature = "wasm")]
2787                notes: None,
2788                #[cfg(feature = "wasm")]
2789                fields: None,
2790                #[cfg(feature = "wasm")]
2791                attachment_names: None,
2792            }
2793        )
2794    }
2795
2796    fn blob_cipher() -> Cipher {
2797        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2798            SymmetricKeyAlgorithm::Aes256CbcHmac,
2799        ));
2800        let cipher: Cipher = key_store
2801            .encrypt(EncryptMode::Blob(generate_cipher()))
2802            .unwrap();
2803        assert!(cipher.data.is_some(), "expected a blob-shaped cipher");
2804        cipher
2805    }
2806
2807    #[test]
2808    fn test_encryption_context_to_cipher_with_id_request_preserves_data() {
2809        let cipher = blob_cipher();
2810        let expected = cipher.data.clone();
2811
2812        let request: CipherWithIdRequestModel = EncryptionContext {
2813            encrypted_for: UserId::new(TEST_UUID.parse().unwrap()),
2814            encrypted_by_key_id: Some("0102030405060708090a0b0c0d0e0f10".to_string()),
2815            cipher,
2816        }
2817        .try_into()
2818        .unwrap();
2819
2820        assert_eq!(request.data, expected);
2821        assert_eq!(
2822            request.encrypted_by_key_id.as_deref(),
2823            Some("0102030405060708090a0b0c0d0e0f10")
2824        );
2825    }
2826
2827    #[test]
2828    fn test_encryption_context_to_cipher_request_preserves_data() {
2829        let cipher = blob_cipher();
2830        let expected = cipher.data.clone();
2831
2832        let request: CipherRequestModel = EncryptionContext {
2833            encrypted_for: UserId::new(TEST_UUID.parse().unwrap()),
2834            encrypted_by_key_id: Some("0102030405060708090a0b0c0d0e0f10".to_string()),
2835            cipher,
2836        }
2837        .into();
2838
2839        assert_eq!(request.data, expected);
2840        assert_eq!(
2841            request.encrypted_by_key_id.as_deref(),
2842            Some("0102030405060708090a0b0c0d0e0f10")
2843        );
2844    }
2845
2846    /// Personal ciphers report the user key's id. This mirrors the lookup the cipher client
2847    /// performs when populating `EncryptionContext::encrypted_by_key_id`.
2848    #[test]
2849    fn test_encrypted_by_key_id_uses_user_key_for_personal_cipher() {
2850        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::XChaCha20Poly1305);
2851        let expected = user_key.key_id().unwrap().to_string();
2852        let key_store = create_test_crypto_with_user_key(user_key);
2853
2854        let view = generate_cipher();
2855        assert_eq!(view.key_identifier(), SymmetricKeySlotId::User);
2856
2857        let actual = key_store
2858            .context()
2859            .get_symmetric_key_id(view.key_identifier())
2860            .map(|id| id.to_string());
2861
2862        assert_eq!(actual.as_deref(), Some(expected.as_str()));
2863        // The server only accepts a lowercase hex encoding of the 16 raw bytes.
2864        assert_eq!(expected.len(), 32);
2865        assert!(expected.chars().all(|c| c.is_ascii_hexdigit()));
2866        assert_eq!(expected, expected.to_lowercase());
2867    }
2868
2869    /// Organization-owned ciphers report the *organization* key's id, not the acting user's.
2870    #[test]
2871    fn test_encrypted_by_key_id_uses_org_key_for_org_cipher() {
2872        let org = OrganizationId::new_v4();
2873        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::XChaCha20Poly1305);
2874        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::XChaCha20Poly1305);
2875        let user_key_id = user_key.key_id().unwrap().to_string();
2876        let org_key_id = org_key.key_id().unwrap().to_string();
2877        assert_ne!(user_key_id, org_key_id);
2878
2879        let key_store = create_test_crypto_with_user_and_org_key(user_key, org, org_key);
2880
2881        let mut view = generate_cipher();
2882        view.organization_id = Some(org);
2883        assert_eq!(view.key_identifier(), SymmetricKeySlotId::Organization(org));
2884
2885        let actual = key_store
2886            .context()
2887            .get_symmetric_key_id(view.key_identifier())
2888            .map(|id| id.to_string());
2889
2890        assert_eq!(actual.as_deref(), Some(org_key_id.as_str()));
2891    }
2892
2893    /// A per-cipher key does not change the answer - the reported id is always the *wrapping* key
2894    /// the cipher key itself is sealed under.
2895    #[test]
2896    fn test_encrypted_by_key_id_ignores_cipher_key() {
2897        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::XChaCha20Poly1305);
2898        let expected = user_key.key_id().unwrap().to_string();
2899        let key_store = create_test_crypto_with_user_key(user_key);
2900
2901        let mut view = generate_cipher();
2902        let _ = view.load_cipher_key_slot(&mut key_store.context()).unwrap();
2903        assert!(view.key.is_some());
2904
2905        let actual = key_store
2906            .context()
2907            .get_symmetric_key_id(view.key_identifier())
2908            .map(|id| id.to_string());
2909
2910        assert_eq!(actual.as_deref(), Some(expected.as_str()));
2911    }
2912
2913    /// V1 accounts use AES-CBC-HMAC keys, which have no stored key id and derive one from their
2914    /// key material instead - so the field is still populated, with that derived id.
2915    #[test]
2916    fn test_encrypted_by_key_id_uses_derived_id_for_legacy_user_key() {
2917        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
2918        let expected = user_key
2919            .key_id()
2920            .expect("an AES-CBC-HMAC key derives a key id")
2921            .to_string();
2922        let key_store = create_test_crypto_with_user_key(user_key);
2923
2924        let view = generate_cipher();
2925        let actual = key_store
2926            .context()
2927            .get_symmetric_key_id(view.key_identifier())
2928            .map(|id| id.to_string());
2929
2930        assert_eq!(actual.as_deref(), Some(expected.as_str()));
2931    }
2932
2933    #[test]
2934    fn test_decrypt_cipher_fails_with_invalid_name() {
2935        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2936            SymmetricKeyAlgorithm::Aes256CbcHmac,
2937        ));
2938
2939        // Encrypt a valid cipher, then swap name with an EncString from a different key
2940        let cipher = key_store
2941            .encrypt(EncryptMode::Legacy(generate_cipher()))
2942            .unwrap();
2943        let cipher = Cipher {
2944            name: Some(TEST_CIPHER_NAME.parse().unwrap()), // encrypted with a different key
2945            ..cipher
2946        };
2947
2948        // Default (lenient) decryption swallows the error, yielding an empty name
2949        let lenient_result: Result<CipherView, _> = key_store.decrypt(&cipher);
2950        assert!(
2951            lenient_result.is_ok(),
2952            "Lenient decryption should succeed even when name is encrypted with a different key"
2953        );
2954        assert_eq!(
2955            lenient_result.unwrap().name,
2956            String::new(),
2957            "Lenient decryption should yield an empty name on error"
2958        );
2959
2960        // Strict decryption propagates the error
2961        let strict_result: Result<CipherView, _> = key_store.decrypt(&StrictDecrypt(cipher));
2962        assert!(
2963            strict_result.is_err(),
2964            "Strict decryption should fail when name is encrypted with a different key"
2965        );
2966    }
2967
2968    #[test]
2969    fn test_decrypt_cipher_fails_with_invalid_login() {
2970        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
2971            SymmetricKeyAlgorithm::Aes256CbcHmac,
2972        ));
2973
2974        // Encrypt a valid cipher, then corrupt the login username
2975        let cipher = key_store
2976            .encrypt(EncryptMode::Legacy(generate_cipher()))
2977            .unwrap();
2978        let cipher = Cipher {
2979            login: Some(Login {
2980                username: Some(TEST_CIPHER_NAME.parse().unwrap()), // encrypted with a different key
2981                ..cipher.login.unwrap()
2982            }),
2983            ..cipher
2984        };
2985
2986        // Default (lenient) decryption swallows the error, yielding None for the username field
2987        let lenient_result: Result<CipherView, _> = key_store.decrypt(&cipher);
2988        assert!(
2989            lenient_result.is_ok(),
2990            "Lenient decryption should succeed even when login username is encrypted with a different key"
2991        );
2992        let lenient_view = lenient_result.unwrap();
2993        assert!(
2994            lenient_view.login.is_some(),
2995            "Lenient decryption should still return the login object"
2996        );
2997        assert!(
2998            lenient_view.login.unwrap().username.is_none(),
2999            "Lenient decryption should null out the failing username field"
3000        );
3001
3002        // Strict decryption propagates the error
3003        let strict_result: Result<CipherView, _> = key_store.decrypt(&StrictDecrypt(cipher));
3004        assert!(
3005            strict_result.is_err(),
3006            "Strict decryption should fail when login username is encrypted with a different key"
3007        );
3008    }
3009
3010    #[test]
3011    fn test_encrypt_legacy_fails_closed_for_partial_view() {
3012        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3013            SymmetricKeyAlgorithm::Aes256CbcHmac,
3014        ));
3015
3016        let mut view = generate_cipher();
3017        view.partial = true;
3018
3019        let result = key_store.encrypt(EncryptMode::Legacy(view));
3020        assert!(matches!(result, Err(CryptoError::EncryptRestrictedView)));
3021    }
3022
3023    #[test]
3024    fn test_encrypt_blob_fails_closed_for_partial_view() {
3025        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3026            SymmetricKeyAlgorithm::Aes256CbcHmac,
3027        ));
3028
3029        let mut view = generate_cipher();
3030        view.partial = true;
3031
3032        let result = key_store.encrypt(EncryptMode::Blob(view));
3033        assert!(matches!(result, Err(CryptoError::EncryptRestrictedView)));
3034    }
3035
3036    #[test]
3037    fn test_encrypt_succeeds_for_non_partial_view() {
3038        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3039            SymmetricKeyAlgorithm::Aes256CbcHmac,
3040        ));
3041
3042        // Control: the same shape of view, but with `partial: false`, must still encrypt
3043        // successfully — proving the guard is specific to `partial` rather than some other
3044        // difference between test fixtures.
3045        let view = generate_cipher();
3046        assert!(!view.partial);
3047
3048        let result = key_store.encrypt(EncryptMode::Legacy(view));
3049        assert!(result.is_ok());
3050    }
3051
3052    #[test]
3053    fn test_load_cipher_key_slot_ignores_attachments_without_key() {
3054        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3055        let key_store = create_test_crypto_with_user_key(key);
3056
3057        let mut cipher = generate_cipher();
3058        let attachment = AttachmentView {
3059            id: None,
3060            url: None,
3061            size: None,
3062            size_name: None,
3063            file_name: Some("Attachment test name".into()),
3064            key: None,
3065        };
3066        cipher.attachments = Some(vec![attachment]);
3067
3068        let _ = cipher
3069            .load_cipher_key_slot(&mut key_store.context())
3070            .unwrap();
3071        assert!(cipher.attachments.unwrap()[0].key.is_none());
3072    }
3073
3074    #[test]
3075    fn test_validate_attachment_keys_with_cipher_key() {
3076        let old_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3077        let key_store = create_test_crypto_with_user_key(old_key);
3078        let mut ctx = key_store.context_mut();
3079
3080        let mut cipher = generate_cipher();
3081        let _ = cipher.load_cipher_key_slot(&mut ctx).unwrap();
3082
3083        cipher.validate_attachment_keys().unwrap();
3084
3085        assert!(cipher.key.is_some());
3086    }
3087
3088    /// A keyless cipher with no attachments has nothing to orphan, so it must stay rotatable.
3089    #[test]
3090    fn test_validate_attachment_keys_without_attachments() {
3091        let mut cipher = generate_cipher();
3092        assert!(cipher.key.is_none());
3093        assert!(cipher.attachments.is_none());
3094
3095        cipher.validate_attachment_keys().unwrap();
3096    }
3097
3098    #[test]
3099    fn test_move_user_cipher_to_org() {
3100        let org = OrganizationId::new_v4();
3101        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3102        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3103        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
3104
3105        // Create a cipher with a user key
3106        let mut cipher = generate_cipher();
3107        let _ = cipher
3108            .load_cipher_key_slot(&mut key_store.context())
3109            .unwrap();
3110
3111        cipher.move_to_organization(org).unwrap();
3112        assert_eq!(cipher.organization_id, Some(org));
3113
3114        // Check that the cipher can be encrypted/decrypted with the new org key
3115        let cipher_enc = key_store.encrypt(EncryptMode::Legacy(cipher)).unwrap();
3116        let cipher_dec: CipherView = key_store.decrypt(&cipher_enc).unwrap();
3117
3118        assert_eq!(cipher_dec.name, "My test login");
3119    }
3120
3121    #[test]
3122    fn test_move_user_cipher_to_org_manually() {
3123        let org = OrganizationId::new_v4();
3124        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3125        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3126        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
3127
3128        // Create a cipher with a user key
3129        let mut cipher = generate_cipher();
3130        let _ = cipher
3131            .load_cipher_key_slot(&mut key_store.context())
3132            .unwrap();
3133
3134        cipher.organization_id = Some(org);
3135
3136        // The cipher key is now stored as raw bytes (not wrapped under the user key), so it can
3137        // be re-wrapped under any available key at encrypt time — this now succeeds.
3138        let cipher_enc = key_store.encrypt(EncryptMode::Legacy(cipher)).unwrap();
3139        let cipher_dec: CipherView = key_store.decrypt(&cipher_enc).unwrap();
3140        assert_eq!(cipher_dec.name, "My test login");
3141    }
3142
3143    #[test]
3144    fn test_move_user_cipher_with_attachment_without_key_to_org() {
3145        let org = OrganizationId::new_v4();
3146        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3147        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3148        let _key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
3149
3150        let mut cipher = generate_cipher();
3151        let attachment = AttachmentView {
3152            id: None,
3153            url: None,
3154            size: None,
3155            size_name: None,
3156            file_name: Some("Attachment test name".into()),
3157            key: None,
3158        };
3159        cipher.attachments = Some(vec![attachment]);
3160
3161        // Neither cipher nor attachment have keys, so the cipher can't be moved
3162        assert!(cipher.move_to_organization(org).is_err());
3163    }
3164
3165    #[test]
3166    fn test_move_user_cipher_with_attachment_with_key_to_org() {
3167        let org = OrganizationId::new_v4();
3168        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3169        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3170        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
3171
3172        // Attachment has a key stored as raw base64 on the view; the cipher itself has no key
3173        let attachment_key_val = {
3174            let mut ctx = key_store.context();
3175            let attachment_key = ctx.generate_symmetric_key();
3176            #[allow(deprecated)]
3177            ctx.dangerous_get_symmetric_key(attachment_key)
3178                .unwrap()
3179                .clone()
3180        };
3181
3182        let mut cipher = generate_cipher();
3183        let attachment = AttachmentView {
3184            id: None,
3185            url: None,
3186            size: None,
3187            size_name: None,
3188            file_name: Some("Attachment test name".into()),
3189            key: Some(attachment_key_val.clone()),
3190        };
3191        cipher.attachments = Some(vec![attachment]);
3192        let cred = generate_fido2_view();
3193        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred]);
3194
3195        cipher.move_to_organization(org).unwrap();
3196
3197        assert!(cipher.key.is_none());
3198
3199        // Attachment raw key bytes are preserved (re-wrapping happens at encrypt time)
3200        assert_eq!(
3201            cipher.attachments.unwrap()[0].key.clone().unwrap(),
3202            attachment_key_val
3203        );
3204
3205        let cred2 = cipher
3206            .login
3207            .unwrap()
3208            .fido2_credentials
3209            .unwrap()
3210            .first()
3211            .unwrap()
3212            .clone();
3213
3214        assert_eq!(cred2.credential_id, "123");
3215    }
3216
3217    #[test]
3218    fn test_move_user_cipher_with_key_with_attachment_with_key_to_org() {
3219        let org = OrganizationId::new_v4();
3220        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3221        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
3222        let key_store = create_test_crypto_with_user_and_org_key(key, org, org_key);
3223
3224        let mut ctx = key_store.context();
3225
3226        let cipher_key = ctx.generate_symmetric_key();
3227        #[allow(deprecated)]
3228        let cipher_key_raw = ctx.dangerous_get_symmetric_key(cipher_key).unwrap().clone();
3229
3230        // Attachment has a key that is encrypted with the cipher key
3231        let attachment_key = ctx.generate_symmetric_key();
3232        #[allow(deprecated)]
3233        let attachment_key_raw = ctx
3234            .dangerous_get_symmetric_key(attachment_key)
3235            .unwrap()
3236            .clone();
3237
3238        let mut cipher = generate_cipher();
3239        cipher.key = Some(cipher_key_raw.clone());
3240
3241        let attachment = AttachmentView {
3242            id: None,
3243            url: None,
3244            size: None,
3245            size_name: None,
3246            file_name: Some("Attachment test name".into()),
3247            key: Some(attachment_key_raw.clone()),
3248        };
3249        cipher.attachments = Some(vec![attachment]);
3250
3251        let cred = generate_fido2_view();
3252        cipher.login.as_mut().unwrap().fido2_credentials = Some(vec![cred.clone()]);
3253
3254        cipher.move_to_organization(org).unwrap();
3255
3256        // Raw cipher key bytes are unchanged (re-wrapping happens at encrypt time)
3257        assert_eq!(cipher.key.clone().unwrap(), cipher_key_raw);
3258
3259        // Attachment raw key bytes are unchanged (re-wrapping happens at encrypt time)
3260        assert_eq!(
3261            cipher.attachments.unwrap()[0].key.as_ref().unwrap(),
3262            &attachment_key_raw
3263        );
3264
3265        let cred2 = cipher
3266            .login
3267            .unwrap()
3268            .fido2_credentials
3269            .unwrap()
3270            .first()
3271            .unwrap()
3272            .clone();
3273
3274        assert_eq!(cred2.credential_id, cred.credential_id);
3275    }
3276
3277    #[test]
3278    fn test_password_history_on_password_change() {
3279        use chrono::Utc;
3280
3281        let original_cipher = generate_cipher();
3282        let mut new_cipher = generate_cipher();
3283
3284        // Change password
3285        if let Some(ref mut login) = new_cipher.login {
3286            login.password = Some("new_password123".to_string());
3287        }
3288
3289        let start = Utc::now();
3290        new_cipher.update_password_history(&original_cipher);
3291        let end = Utc::now();
3292
3293        assert!(new_cipher.password_history.is_some());
3294        let history = new_cipher.password_history.unwrap();
3295        assert_eq!(history.len(), 1);
3296        assert_eq!(history[0].password, "test_password");
3297        assert!(
3298            history[0].last_used_date >= start && history[0].last_used_date <= end,
3299            "last_used_date was not set properly"
3300        );
3301    }
3302
3303    #[test]
3304    fn test_password_history_on_unchanged_password() {
3305        let original_cipher = generate_cipher();
3306        let mut new_cipher = generate_cipher();
3307
3308        new_cipher.update_password_history(&original_cipher);
3309
3310        // Password history should be empty since password didn't change
3311        assert!(
3312            new_cipher.password_history.is_none()
3313                || new_cipher.password_history.as_ref().unwrap().is_empty()
3314        );
3315    }
3316
3317    #[test]
3318    fn test_password_history_is_preserved() {
3319        use chrono::TimeZone;
3320
3321        let mut original_cipher = generate_cipher();
3322        original_cipher.password_history = Some(
3323            (0..4)
3324                .map(|i| PasswordHistoryView {
3325                    password: format!("old_password_{}", i),
3326                    last_used_date: chrono::Utc
3327                        .with_ymd_and_hms(2025, i + 1, i + 1, i, i, i)
3328                        .unwrap(),
3329                })
3330                .collect(),
3331        );
3332
3333        let mut new_cipher = generate_cipher();
3334
3335        new_cipher.update_password_history(&original_cipher);
3336
3337        assert!(new_cipher.password_history.is_some());
3338        let history = new_cipher.password_history.unwrap();
3339        assert_eq!(history.len(), 4);
3340
3341        assert_eq!(history[0].password, "old_password_0");
3342        assert_eq!(
3343            history[0].last_used_date,
3344            chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
3345        );
3346        assert_eq!(history[1].password, "old_password_1");
3347        assert_eq!(
3348            history[1].last_used_date,
3349            chrono::Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
3350        );
3351        assert_eq!(history[2].password, "old_password_2");
3352        assert_eq!(
3353            history[2].last_used_date,
3354            chrono::Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
3355        );
3356        assert_eq!(history[3].password, "old_password_3");
3357        assert_eq!(
3358            history[3].last_used_date,
3359            chrono::Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
3360        );
3361    }
3362
3363    #[test]
3364    fn test_populate_cipher_types_login_with_valid_data() {
3365        let mut cipher = Cipher {
3366            partial_data: None,
3367            id: Some(TEST_UUID.parse().unwrap()),
3368            organization_id: None,
3369            folder_id: None,
3370            collection_ids: vec![],
3371            key: None,
3372            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3373            notes: None,
3374            r#type: CipherType::Login,
3375            login: None,
3376            identity: None,
3377            card: None,
3378            secure_note: None,
3379            ssh_key: None,
3380            bank_account: None,
3381            drivers_license: None,
3382            passport: None,
3383            favorite: false,
3384            reprompt: CipherRepromptType::None,
3385            organization_use_totp: false,
3386            edit: true,
3387            view_password: true,
3388            permissions: None,
3389            local_data: None,
3390            attachments: None,
3391            fields: None,
3392            password_history: None,
3393            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3394            deleted_date: None,
3395            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3396            archived_date: None,
3397            data: Some(format!(
3398                r#"{{"version": 2, "username": "{}", "password": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
3399                TEST_ENC_STRING_1, TEST_ENC_STRING_2
3400            )),
3401        };
3402
3403        cipher
3404            .populate_cipher_types()
3405            .expect("populate_cipher_types failed");
3406
3407        assert!(cipher.login.is_some());
3408        let login = cipher.login.unwrap();
3409        assert_eq!(login.username.unwrap().to_string(), TEST_ENC_STRING_1);
3410        assert_eq!(login.password.unwrap().to_string(), TEST_ENC_STRING_2);
3411    }
3412
3413    #[test]
3414    fn test_populate_cipher_types_secure_note() {
3415        let mut cipher = Cipher {
3416            partial_data: None,
3417            id: Some(TEST_UUID.parse().unwrap()),
3418            organization_id: None,
3419            folder_id: None,
3420            collection_ids: vec![],
3421            key: None,
3422            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3423            notes: None,
3424            r#type: CipherType::SecureNote,
3425            login: None,
3426            identity: None,
3427            card: None,
3428            secure_note: None,
3429            ssh_key: None,
3430            bank_account: None,
3431            drivers_license: None,
3432            passport: None,
3433            favorite: false,
3434            reprompt: CipherRepromptType::None,
3435            organization_use_totp: false,
3436            edit: true,
3437            view_password: true,
3438            permissions: None,
3439            local_data: None,
3440            attachments: None,
3441            fields: None,
3442            password_history: None,
3443            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3444            deleted_date: None,
3445            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3446            archived_date: None,
3447            data: Some(r#"{"type": 0, "organizationUseTotp": false, "favorite": false, "deletedDate": null}"#.to_string()),
3448        };
3449
3450        cipher
3451            .populate_cipher_types()
3452            .expect("populate_cipher_types failed");
3453
3454        assert!(cipher.secure_note.is_some());
3455    }
3456
3457    #[test]
3458    fn test_populate_cipher_types_card() {
3459        let mut cipher = Cipher {
3460            partial_data: None,
3461            id: Some(TEST_UUID.parse().unwrap()),
3462            organization_id: None,
3463            folder_id: None,
3464            collection_ids: vec![],
3465            key: None,
3466            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3467            notes: None,
3468            r#type: CipherType::Card,
3469            login: None,
3470            identity: None,
3471            card: None,
3472            secure_note: None,
3473            ssh_key: None,
3474            bank_account: None,
3475            drivers_license: None,
3476            passport: None,
3477            favorite: false,
3478            reprompt: CipherRepromptType::None,
3479            organization_use_totp: false,
3480            edit: true,
3481            view_password: true,
3482            permissions: None,
3483            local_data: None,
3484            attachments: None,
3485            fields: None,
3486            password_history: None,
3487            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3488            deleted_date: None,
3489            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3490            archived_date: None,
3491            data: Some(format!(
3492                r#"{{"cardholderName": "{}", "number": "{}", "expMonth": "{}", "expYear": "{}", "code": "{}", "brand": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
3493                TEST_ENC_STRING_1,
3494                TEST_ENC_STRING_2,
3495                TEST_ENC_STRING_3,
3496                TEST_ENC_STRING_4,
3497                TEST_ENC_STRING_5,
3498                TEST_ENC_STRING_1
3499            )),
3500        };
3501
3502        cipher
3503            .populate_cipher_types()
3504            .expect("populate_cipher_types failed");
3505
3506        assert!(cipher.card.is_some());
3507        let card = cipher.card.unwrap();
3508        assert_eq!(
3509            card.cardholder_name.as_ref().unwrap().to_string(),
3510            TEST_ENC_STRING_1
3511        );
3512        assert_eq!(card.number.as_ref().unwrap().to_string(), TEST_ENC_STRING_2);
3513        assert_eq!(
3514            card.exp_month.as_ref().unwrap().to_string(),
3515            TEST_ENC_STRING_3
3516        );
3517        assert_eq!(
3518            card.exp_year.as_ref().unwrap().to_string(),
3519            TEST_ENC_STRING_4
3520        );
3521        assert_eq!(card.code.as_ref().unwrap().to_string(), TEST_ENC_STRING_5);
3522        assert_eq!(card.brand.as_ref().unwrap().to_string(), TEST_ENC_STRING_1);
3523    }
3524
3525    #[test]
3526    fn test_populate_cipher_types_identity() {
3527        let mut cipher = Cipher {
3528            partial_data: None,
3529            id: Some(TEST_UUID.parse().unwrap()),
3530            organization_id: None,
3531            folder_id: None,
3532            collection_ids: vec![],
3533            key: None,
3534            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3535            notes: None,
3536            r#type: CipherType::Identity,
3537            login: None,
3538            identity: None,
3539            card: None,
3540            secure_note: None,
3541            ssh_key: None,
3542            bank_account: None,
3543            drivers_license: None,
3544            passport: None,
3545            favorite: false,
3546            reprompt: CipherRepromptType::None,
3547            organization_use_totp: false,
3548            edit: true,
3549            view_password: true,
3550            permissions: None,
3551            local_data: None,
3552            attachments: None,
3553            fields: None,
3554            password_history: None,
3555            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3556            deleted_date: None,
3557            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3558            archived_date: None,
3559            data: Some(format!(
3560                r#"{{"firstName": "{}", "lastName": "{}", "email": "{}", "phone": "{}", "company": "{}", "address1": "{}", "city": "{}", "state": "{}", "postalCode": "{}", "country": "{}", "organizationUseTotp": false, "favorite": true, "deletedDate": null}}"#,
3561                TEST_ENC_STRING_1,
3562                TEST_ENC_STRING_2,
3563                TEST_ENC_STRING_3,
3564                TEST_ENC_STRING_4,
3565                TEST_ENC_STRING_5,
3566                TEST_ENC_STRING_1,
3567                TEST_ENC_STRING_2,
3568                TEST_ENC_STRING_3,
3569                TEST_ENC_STRING_4,
3570                TEST_ENC_STRING_5
3571            )),
3572        };
3573
3574        cipher
3575            .populate_cipher_types()
3576            .expect("populate_cipher_types failed");
3577
3578        assert!(cipher.identity.is_some());
3579        let identity = cipher.identity.unwrap();
3580        assert_eq!(
3581            identity.first_name.as_ref().unwrap().to_string(),
3582            TEST_ENC_STRING_1
3583        );
3584        assert_eq!(
3585            identity.last_name.as_ref().unwrap().to_string(),
3586            TEST_ENC_STRING_2
3587        );
3588        assert_eq!(
3589            identity.email.as_ref().unwrap().to_string(),
3590            TEST_ENC_STRING_3
3591        );
3592        assert_eq!(
3593            identity.phone.as_ref().unwrap().to_string(),
3594            TEST_ENC_STRING_4
3595        );
3596        assert_eq!(
3597            identity.company.as_ref().unwrap().to_string(),
3598            TEST_ENC_STRING_5
3599        );
3600        assert_eq!(
3601            identity.address1.as_ref().unwrap().to_string(),
3602            TEST_ENC_STRING_1
3603        );
3604        assert_eq!(
3605            identity.city.as_ref().unwrap().to_string(),
3606            TEST_ENC_STRING_2
3607        );
3608        assert_eq!(
3609            identity.state.as_ref().unwrap().to_string(),
3610            TEST_ENC_STRING_3
3611        );
3612        assert_eq!(
3613            identity.postal_code.as_ref().unwrap().to_string(),
3614            TEST_ENC_STRING_4
3615        );
3616        assert_eq!(
3617            identity.country.as_ref().unwrap().to_string(),
3618            TEST_ENC_STRING_5
3619        );
3620    }
3621
3622    #[test]
3623
3624    fn test_password_history_with_hidden_fields() {
3625        let mut original_cipher = generate_cipher();
3626        original_cipher.fields = Some(vec![FieldView {
3627            name: Some("Secret Key".to_string()),
3628            value: Some("old_secret_value".to_string()),
3629            r#type: crate::FieldType::Hidden,
3630            linked_id: None,
3631        }]);
3632
3633        let mut new_cipher = generate_cipher();
3634        new_cipher.fields = Some(vec![FieldView {
3635            name: Some("Secret Key".to_string()),
3636            value: Some("new_secret_value".to_string()),
3637            r#type: crate::FieldType::Hidden,
3638            linked_id: None,
3639        }]);
3640
3641        new_cipher.update_password_history(&original_cipher);
3642
3643        assert!(new_cipher.password_history.is_some());
3644        let history = new_cipher.password_history.unwrap();
3645        assert_eq!(history.len(), 1);
3646        assert_eq!(history[0].password, "Secret Key: old_secret_value");
3647    }
3648
3649    #[test]
3650    fn test_password_history_length_limit() {
3651        use crate::password_history::MAX_PASSWORD_HISTORY_ENTRIES;
3652
3653        let mut original_cipher = generate_cipher();
3654        original_cipher.password_history = Some(
3655            (0..10)
3656                .map(|i| PasswordHistoryView {
3657                    password: format!("old_password_{}", i),
3658                    last_used_date: chrono::Utc::now(),
3659                })
3660                .collect(),
3661        );
3662
3663        let mut new_cipher = original_cipher.clone();
3664        // Change password
3665        if let Some(ref mut login) = new_cipher.login {
3666            login.password = Some("brand_new_password".to_string());
3667        }
3668
3669        new_cipher.update_password_history(&original_cipher);
3670
3671        assert!(new_cipher.password_history.is_some());
3672        let history = new_cipher.password_history.unwrap();
3673
3674        // Should be limited to MAX_PASSWORD_HISTORY_ENTRIES
3675        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
3676
3677        // Most recent change (original password) should be first
3678        assert_eq!(history[0].password, "test_password");
3679        // Followed by the oldest entries from the existing history
3680        assert_eq!(history[1].password, "old_password_0");
3681        assert_eq!(history[2].password, "old_password_1");
3682        assert_eq!(history[3].password, "old_password_2");
3683        assert_eq!(history[4].password, "old_password_3");
3684    }
3685
3686    #[test]
3687    fn test_populate_cipher_types_ssh_key() {
3688        let mut cipher = Cipher {
3689            partial_data: None,
3690            id: Some(TEST_UUID.parse().unwrap()),
3691            organization_id: None,
3692            folder_id: None,
3693            collection_ids: vec![],
3694            key: None,
3695            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3696            notes: None,
3697            r#type: CipherType::SshKey,
3698            login: None,
3699            identity: None,
3700            card: None,
3701            secure_note: None,
3702            ssh_key: None,
3703            bank_account: None,
3704            drivers_license: None,
3705            passport: None,
3706            favorite: false,
3707            reprompt: CipherRepromptType::None,
3708            organization_use_totp: false,
3709            edit: true,
3710            view_password: true,
3711            permissions: None,
3712            local_data: None,
3713            attachments: None,
3714            fields: None,
3715            password_history: None,
3716            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3717            deleted_date: None,
3718            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3719            archived_date: None,
3720            data: Some(format!(
3721                r#"{{"privateKey": "{}", "publicKey": "{}", "fingerprint": "{}", "organizationUseTotp": true, "favorite": false, "deletedDate": null}}"#,
3722                TEST_ENC_STRING_1, TEST_ENC_STRING_2, TEST_ENC_STRING_3
3723            )),
3724        };
3725
3726        cipher
3727            .populate_cipher_types()
3728            .expect("populate_cipher_types failed");
3729
3730        assert!(cipher.ssh_key.is_some());
3731        let ssh_key = cipher.ssh_key.unwrap();
3732        assert_eq!(ssh_key.private_key.to_string(), TEST_ENC_STRING_1);
3733        assert_eq!(ssh_key.public_key.unwrap().to_string(), TEST_ENC_STRING_2);
3734        assert_eq!(ssh_key.fingerprint.unwrap().to_string(), TEST_ENC_STRING_3);
3735    }
3736
3737    #[test]
3738    fn test_populate_cipher_types_with_null_data() {
3739        let mut cipher = Cipher {
3740            partial_data: None,
3741            id: Some(TEST_UUID.parse().unwrap()),
3742            organization_id: None,
3743            folder_id: None,
3744            collection_ids: vec![],
3745            key: None,
3746            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3747            notes: None,
3748            r#type: CipherType::Login,
3749            login: None,
3750            identity: None,
3751            card: None,
3752            secure_note: None,
3753            ssh_key: None,
3754            bank_account: None,
3755            drivers_license: None,
3756            passport: None,
3757            favorite: false,
3758            reprompt: CipherRepromptType::None,
3759            organization_use_totp: false,
3760            edit: true,
3761            view_password: true,
3762            permissions: None,
3763            local_data: None,
3764            attachments: None,
3765            fields: None,
3766            password_history: None,
3767            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3768            deleted_date: None,
3769            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3770            archived_date: None,
3771            data: None,
3772        };
3773
3774        let result = cipher.populate_cipher_types();
3775        assert!(matches!(
3776            result,
3777            Err(VaultParseError::MissingField(MissingFieldError("data")))
3778        ));
3779    }
3780
3781    #[test]
3782    fn test_populate_cipher_types_with_invalid_json() {
3783        let mut cipher = Cipher {
3784            partial_data: None,
3785            id: Some(TEST_UUID.parse().unwrap()),
3786            organization_id: None,
3787            folder_id: None,
3788            collection_ids: vec![],
3789            key: None,
3790            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3791            notes: None,
3792            r#type: CipherType::Login,
3793            login: None,
3794            identity: None,
3795            card: None,
3796            secure_note: None,
3797            ssh_key: None,
3798            bank_account: None,
3799            drivers_license: None,
3800            passport: None,
3801            favorite: false,
3802            reprompt: CipherRepromptType::None,
3803            organization_use_totp: false,
3804            edit: true,
3805            view_password: true,
3806            permissions: None,
3807            local_data: None,
3808            attachments: None,
3809            fields: None,
3810            password_history: None,
3811            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3812            deleted_date: None,
3813            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3814            archived_date: None,
3815            data: Some("invalid json".to_string()),
3816        };
3817
3818        let result = cipher.populate_cipher_types();
3819
3820        assert!(matches!(result, Err(VaultParseError::SerdeJson(_))));
3821    }
3822
3823    #[test]
3824    fn test_decrypt_cipher_with_mixed_attachments() {
3825        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
3826        let key_store = create_test_crypto_with_user_key(user_key);
3827
3828        // Create properly encrypted attachments
3829        let mut ctx = key_store.context();
3830        let valid1 = "valid_file_1.txt"
3831            .encrypt(&mut ctx, SymmetricKeySlotId::User)
3832            .unwrap();
3833        let valid2 = "valid_file_2.txt"
3834            .encrypt(&mut ctx, SymmetricKeySlotId::User)
3835            .unwrap();
3836
3837        // Create corrupted attachment by encrypting with a random different key
3838        let wrong_key: SymmetricCryptoKey = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==".to_string().try_into().unwrap();
3839        let wrong_key_store = create_test_crypto_with_user_key(wrong_key);
3840        let mut wrong_ctx = wrong_key_store.context();
3841        let corrupted = "corrupted_file.txt"
3842            .encrypt(&mut wrong_ctx, SymmetricKeySlotId::User)
3843            .unwrap();
3844
3845        let cipher = Cipher {
3846            partial_data: None,
3847            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
3848            organization_id: None,
3849            folder_id: None,
3850            collection_ids: vec![],
3851            key: None,
3852            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
3853            notes: None,
3854            r#type: CipherType::Login,
3855            login: None,
3856            identity: None,
3857            card: None,
3858            secure_note: None,
3859            ssh_key: None,
3860            bank_account: None,
3861            drivers_license: None,
3862            passport: None,
3863            favorite: false,
3864            reprompt: CipherRepromptType::None,
3865            organization_use_totp: false,
3866            edit: true,
3867            permissions: None,
3868            view_password: true,
3869            local_data: None,
3870            attachments: Some(vec![
3871                // Valid attachment
3872                attachment::Attachment {
3873                    id: Some("valid-attachment".to_string()),
3874                    url: Some("https://example.com/valid".to_string()),
3875                    size: Some("100".to_string()),
3876                    size_name: Some("100 Bytes".to_string()),
3877                    file_name: Some(valid1),
3878                    key: None,
3879                },
3880                // Corrupted attachment
3881                attachment::Attachment {
3882                    id: Some("corrupted-attachment".to_string()),
3883                    url: Some("https://example.com/corrupted".to_string()),
3884                    size: Some("200".to_string()),
3885                    size_name: Some("200 Bytes".to_string()),
3886                    file_name: Some(corrupted),
3887                    key: None,
3888                },
3889                // Another valid attachment
3890                attachment::Attachment {
3891                    id: Some("valid-attachment-2".to_string()),
3892                    url: Some("https://example.com/valid2".to_string()),
3893                    size: Some("150".to_string()),
3894                    size_name: Some("150 Bytes".to_string()),
3895                    file_name: Some(valid2),
3896                    key: None,
3897                },
3898            ]),
3899            fields: None,
3900            password_history: None,
3901            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3902            deleted_date: None,
3903            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
3904            archived_date: None,
3905            data: None,
3906        };
3907
3908        let view: CipherView = key_store.decrypt(&cipher).unwrap();
3909
3910        // Should have 2 successful attachments
3911        assert!(view.attachments.is_some());
3912        let successes = view.attachments.as_ref().unwrap();
3913        assert_eq!(successes.len(), 2);
3914        assert_eq!(successes[0].id, Some("valid-attachment".to_string()));
3915        assert_eq!(successes[1].id, Some("valid-attachment-2".to_string()));
3916
3917        // Should have 1 failed attachment
3918        assert!(view.attachment_decryption_failures.is_some());
3919        let failures = view.attachment_decryption_failures.as_ref().unwrap();
3920        assert_eq!(failures.len(), 1);
3921        assert_eq!(failures[0].id, Some("corrupted-attachment".to_string()));
3922        assert_eq!(failures[0].file_name, None);
3923    }
3924
3925    #[test]
3926    fn test_decrypt_cipher_list_view_passport() {
3927        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3928            SymmetricKeyAlgorithm::Aes256CbcHmac,
3929        ));
3930
3931        let cipher_view = CipherView {
3932            r#type: CipherType::Passport,
3933            passport: Some(passport::PassportView {
3934                given_name: Some("Jane".to_string()),
3935                surname: Some("Doe".to_string()),
3936                passport_number: Some("P12345678".to_string()),
3937                ..Default::default()
3938            }),
3939            login: None,
3940            ..generate_cipher()
3941        };
3942
3943        let cipher: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3944        let list_view: CipherListView = key_store.decrypt(&cipher).unwrap();
3945
3946        assert_eq!(list_view.r#type, CipherListViewType::Passport);
3947        assert_eq!(list_view.subtitle, "Jane Doe");
3948        assert_eq!(
3949            list_view.copyable_fields,
3950            vec![
3951                CopyableCipherFields::PassportGivenName,
3952                CopyableCipherFields::PassportSurname,
3953                CopyableCipherFields::PassportPassportNumber,
3954            ]
3955        );
3956    }
3957
3958    #[test]
3959    fn test_decrypt_cipher_list_view_drivers_license() {
3960        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3961            SymmetricKeyAlgorithm::Aes256CbcHmac,
3962        ));
3963
3964        let cipher_view = CipherView {
3965            r#type: CipherType::DriversLicense,
3966            drivers_license: Some(drivers_license::DriversLicenseView {
3967                first_name: Some("John".to_string()),
3968                last_name: Some("Doe".to_string()),
3969                license_number: Some("DL-987654".to_string()),
3970                ..Default::default()
3971            }),
3972            login: None,
3973            ..generate_cipher()
3974        };
3975
3976        let cipher: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
3977        let list_view: CipherListView = key_store.decrypt(&cipher).unwrap();
3978
3979        assert_eq!(list_view.r#type, CipherListViewType::DriversLicense);
3980        assert_eq!(list_view.subtitle, "John Doe");
3981        assert_eq!(
3982            list_view.copyable_fields,
3983            vec![
3984                CopyableCipherFields::DriversLicenseFirstName,
3985                CopyableCipherFields::DriversLicenseLastName,
3986                CopyableCipherFields::DriversLicenseLicenseNumber,
3987            ]
3988        );
3989    }
3990
3991    #[test]
3992    fn test_cipher_view_encrypt_decrypt_passport() {
3993        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
3994            SymmetricKeyAlgorithm::Aes256CbcHmac,
3995        ));
3996
3997        let passport = passport::PassportView {
3998            given_name: Some("Jane".to_string()),
3999            surname: Some("Doe".to_string()),
4000            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
4001            sex: Some("F".to_string()),
4002            birth_place: Some("New York".to_string()),
4003            nationality: Some("American".to_string()),
4004            issuing_country: Some("US".to_string()),
4005            passport_number: Some("P12345678".to_string()),
4006            passport_type: Some("P".to_string()),
4007            national_identification_number: Some("123-45-6789".to_string()),
4008            issuing_authority: Some("US State Department".to_string()),
4009            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4010            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
4011        };
4012
4013        let cipher_view = CipherView {
4014            r#type: CipherType::Passport,
4015            passport: Some(passport.clone()),
4016            login: None,
4017            ..generate_cipher()
4018        };
4019
4020        let encrypted: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
4021        let decrypted: CipherView = key_store.decrypt(&encrypted).unwrap();
4022
4023        assert_eq!(decrypted.r#type, CipherType::Passport);
4024        assert_eq!(decrypted.passport, Some(passport));
4025        assert!(decrypted.login.is_none());
4026    }
4027
4028    #[test]
4029    fn test_cipher_view_encrypt_decrypt_drivers_license() {
4030        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4031            SymmetricKeyAlgorithm::Aes256CbcHmac,
4032        ));
4033
4034        let dl = drivers_license::DriversLicenseView {
4035            first_name: Some("John".to_string()),
4036            middle_name: Some("Michael".to_string()),
4037            last_name: Some("Doe".to_string()),
4038            date_of_birth: chrono::NaiveDate::from_ymd_opt(1985, 6, 15),
4039            license_number: Some("DL-987654".to_string()),
4040            issuing_country: Some("US".to_string()),
4041            issuing_state: Some("NY".to_string()),
4042            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4043            expiration_date: chrono::NaiveDate::from_ymd_opt(2028, 1, 1),
4044            issuing_authority: Some("NY DMV".to_string()),
4045            license_class: Some("D".to_string()),
4046        };
4047
4048        let cipher_view = CipherView {
4049            r#type: CipherType::DriversLicense,
4050            drivers_license: Some(dl.clone()),
4051            login: None,
4052            ..generate_cipher()
4053        };
4054
4055        let encrypted: Cipher = key_store.encrypt(EncryptMode::Legacy(cipher_view)).unwrap();
4056        let decrypted: CipherView = key_store.decrypt(&encrypted).unwrap();
4057
4058        assert_eq!(decrypted.r#type, CipherType::DriversLicense);
4059        assert_eq!(decrypted.drivers_license, Some(dl));
4060        assert!(decrypted.login.is_none());
4061    }
4062
4063    #[test]
4064    fn test_mini_response_model_view_password_defaults_to_true() {
4065        use chrono::Utc;
4066
4067        // CipherMiniResponseModel does not include view_password from the API,
4068        // so when merge_with_cipher is called with None, it should default to true
4069        let mini_response = CipherMiniResponseModel {
4070            id: Some(TEST_UUID.parse().unwrap()),
4071            name: Some(TEST_CIPHER_NAME.to_string()),
4072            r#type: Some(bitwarden_api_api::models::CipherType::Login),
4073            creation_date: Some(Utc::now().to_rfc3339()),
4074            revision_date: Some(Utc::now().to_rfc3339()),
4075            ..Default::default()
4076        };
4077
4078        let cipher = mini_response.merge_with_cipher(None).unwrap();
4079        assert!(
4080            cipher.view_password,
4081            "view_password should default to true for CipherMiniResponseModel"
4082        );
4083
4084        // CipherMiniDetailsResponseModel should also default to true
4085        let mini_details_response = CipherMiniDetailsResponseModel {
4086            id: Some(TEST_UUID.parse().unwrap()),
4087            name: Some(TEST_CIPHER_NAME.to_string()),
4088            r#type: Some(bitwarden_api_api::models::CipherType::Login),
4089            creation_date: Some(Utc::now().to_rfc3339()),
4090            revision_date: Some(Utc::now().to_rfc3339()),
4091            ..Default::default()
4092        };
4093
4094        let cipher = mini_details_response.merge_with_cipher(None).unwrap();
4095        assert!(
4096            cipher.view_password,
4097            "view_password should default to true for CipherMiniDetailsResponseModel"
4098        );
4099    }
4100
4101    /// PAM gating is authoritative from the server response: `merge_with_cipher` takes
4102    /// `partial_data` from the response, so it gates when the response carries the restricted
4103    /// envelope and un-gates when it does not — regardless of the local cipher's prior state.
4104    #[test]
4105    fn test_merge_takes_partial_data_from_response() {
4106        use chrono::Utc;
4107
4108        let org: OrganizationId = RESTRICTED_ORG_UUID.parse().unwrap();
4109        // A local cipher that is currently PAM-gated; the response's gating must win over it.
4110        let local_restricted = || {
4111            Some(restricted_cipher(
4112                org,
4113                CipherType::Login,
4114                RESTRICTED_LOGIN_ENVELOPE.to_string(),
4115            ))
4116        };
4117        let now = Utc::now().to_rfc3339();
4118
4119        macro_rules! assert_gating_from_response {
4120            ($model:ident) => {{
4121                let base = $model {
4122                    id: Some(TEST_UUID.parse().unwrap()),
4123                    organization_id: Some(org.to_string().parse().unwrap()),
4124                    r#type: Some(bitwarden_api_api::models::CipherType::Login),
4125                    creation_date: Some(now.clone()),
4126                    revision_date: Some(now.clone()),
4127                    ..Default::default()
4128                };
4129
4130                // A full response (no `partial_data`) un-gates a locally-restricted cipher.
4131                assert_eq!(
4132                    base.clone()
4133                        .merge_with_cipher(local_restricted())
4134                        .unwrap()
4135                        .partial_data,
4136                    None,
4137                    concat!(stringify!($model), ": full response must un-gate"),
4138                );
4139
4140                // A restricted response gates the row even when the local cipher was full.
4141                let restricted = $model {
4142                    partial_data: Some(RESTRICTED_LOGIN_ENVELOPE.to_string()),
4143                    ..base
4144                };
4145                assert_eq!(
4146                    restricted
4147                        .merge_with_cipher(None)
4148                        .unwrap()
4149                        .partial_data
4150                        .as_deref(),
4151                    Some(RESTRICTED_LOGIN_ENVELOPE),
4152                    concat!(stringify!($model), ": restricted response must gate"),
4153                );
4154            }};
4155        }
4156
4157        assert_gating_from_response!(CipherResponseModel);
4158        assert_gating_from_response!(CipherDetailsResponseModel);
4159        assert_gating_from_response!(CipherMiniResponseModel);
4160        assert_gating_from_response!(CipherMiniDetailsResponseModel);
4161    }
4162
4163    // ---------- Cipher Decryptable dispatch + CipherView::to_list_view ----------
4164
4165    mod cipher_decrypt_dispatch {
4166        use bitwarden_crypto::KeyStore;
4167
4168        use super::*;
4169        use crate::{
4170            BankAccountView, CardView, DriversLicenseView, IdentityView, PassportView,
4171            SecureNoteType, SecureNoteView, SshKeyView, cipher::blob::encrypt_blob_cipher,
4172        };
4173
4174        fn make_key_store() -> KeyStore<KeySlotIds> {
4175            create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4176                SymmetricKeyAlgorithm::Aes256CbcHmac,
4177            ))
4178        }
4179
4180        /// Encrypt a view through the legacy field-level path.
4181        fn encrypt_legacy(view: CipherView, key_store: &KeyStore<KeySlotIds>) -> Cipher {
4182            key_store.encrypt(EncryptMode::Legacy(view)).unwrap()
4183        }
4184
4185        /// Encrypt a view through the blob path.
4186        fn encrypt_blob(mut view: CipherView, key_store: &KeyStore<KeySlotIds>) -> Cipher {
4187            let mut ctx = key_store.context_mut();
4188            encrypt_blob_cipher(&mut view, &mut ctx).unwrap()
4189        }
4190
4191        fn base_login_view() -> CipherView {
4192            let mut view = generate_cipher();
4193            view.name = "Test Login".to_string();
4194            view.login = Some(LoginView {
4195                username: Some("[email protected]".to_string()),
4196                password: Some("hunter2".to_string()),
4197                password_revision_date: None,
4198                uris: None,
4199                totp: Some("otpauth://totp/test?secret=SECRET".to_string()),
4200                autofill_on_page_load: None,
4201                fido2_credentials: None,
4202            });
4203            view
4204        }
4205
4206        /// Blob cipher → `CipherView` dispatch works end-to-end.
4207        #[test]
4208        fn dispatches_blob_to_cipher_view() {
4209            let key_store = make_key_store();
4210            let cipher = encrypt_blob(base_login_view(), &key_store);
4211
4212            let view: CipherView = key_store.decrypt(&cipher).unwrap();
4213
4214            assert_eq!(view.name, "Test Login");
4215            let login = view.login.expect("blob decrypt should restore login");
4216            assert_eq!(login.username.as_deref(), Some("[email protected]"));
4217            assert_eq!(login.password.as_deref(), Some("hunter2"));
4218        }
4219
4220        /// Legacy cipher → `CipherView` dispatch works via both default (lenient) and strict
4221        /// paths.
4222        #[test]
4223        fn dispatches_legacy_to_cipher_view() {
4224            let key_store = make_key_store();
4225
4226            // Default (lenient) path on `Cipher`.
4227            let cipher = encrypt_legacy(base_login_view(), &key_store);
4228            let view: CipherView = key_store.decrypt(&cipher).unwrap();
4229            assert_eq!(view.name, "Test Login");
4230            assert_eq!(
4231                view.login.unwrap().username.as_deref(),
4232                Some("[email protected]"),
4233            );
4234
4235            // Strict path via `StrictDecrypt<Cipher>`.
4236            let cipher = encrypt_legacy(base_login_view(), &key_store);
4237            let view: CipherView = key_store.decrypt(&StrictDecrypt(cipher)).unwrap();
4238            assert_eq!(view.name, "Test Login");
4239            assert_eq!(
4240                view.login.unwrap().username.as_deref(),
4241                Some("[email protected]"),
4242            );
4243        }
4244
4245        /// Blob ciphers of every type produce a well-formed `CipherListView`.
4246        ///
4247        /// Exercises each arm of [`CipherView::to_list_view`]: subtitle derivation,
4248        /// list-view type discriminant, and `copyable_fields`.
4249        #[test]
4250        fn blob_to_list_view_per_type() {
4251            let key_store = make_key_store();
4252
4253            // --- Login ---
4254            {
4255                let list_view = decrypt_blob_list_view(&key_store, base_login_view());
4256                assert_eq!(list_view.name, "Test Login");
4257                assert_eq!(list_view.subtitle, "[email protected]");
4258                assert!(matches!(list_view.r#type, CipherListViewType::Login(_)));
4259                assert!(
4260                    list_view
4261                        .copyable_fields
4262                        .contains(&CopyableCipherFields::LoginUsername)
4263                );
4264                assert!(
4265                    list_view
4266                        .copyable_fields
4267                        .contains(&CopyableCipherFields::LoginPassword)
4268                );
4269                assert!(
4270                    list_view
4271                        .copyable_fields
4272                        .contains(&CopyableCipherFields::LoginTotp)
4273                );
4274            }
4275
4276            // --- Card ---
4277            {
4278                let mut view = generate_cipher();
4279                view.r#type = CipherType::Card;
4280                view.login = None;
4281                view.name = "My Card".to_string();
4282                view.card = Some(CardView {
4283                    cardholder_name: Some("John Doe".to_string()),
4284                    exp_month: Some("12".to_string()),
4285                    exp_year: Some("2030".to_string()),
4286                    code: Some("123".to_string()),
4287                    brand: Some("Visa".to_string()),
4288                    number: Some("4111111111111111".to_string()),
4289                });
4290                let list_view = decrypt_blob_list_view(&key_store, view);
4291                assert_eq!(list_view.name, "My Card");
4292                assert!(list_view.subtitle.contains("Visa"));
4293                assert!(list_view.subtitle.contains("1111"));
4294                match &list_view.r#type {
4295                    CipherListViewType::Card(card) => {
4296                        assert_eq!(card.brand.as_deref(), Some("Visa"))
4297                    }
4298                    other => panic!("expected Card, got {other:?}"),
4299                }
4300                assert!(
4301                    list_view
4302                        .copyable_fields
4303                        .contains(&CopyableCipherFields::CardNumber)
4304                );
4305                assert!(
4306                    list_view
4307                        .copyable_fields
4308                        .contains(&CopyableCipherFields::CardSecurityCode)
4309                );
4310            }
4311
4312            // --- Identity ---
4313            {
4314                let mut view = generate_cipher();
4315                view.r#type = CipherType::Identity;
4316                view.login = None;
4317                view.name = "My Identity".to_string();
4318                view.identity = Some(IdentityView {
4319                    title: None,
4320                    first_name: Some("Jane".to_string()),
4321                    middle_name: None,
4322                    last_name: Some("Doe".to_string()),
4323                    address1: Some("123 Main St".to_string()),
4324                    address2: None,
4325                    address3: None,
4326                    city: None,
4327                    state: None,
4328                    postal_code: None,
4329                    country: None,
4330                    company: None,
4331                    email: Some("[email protected]".to_string()),
4332                    phone: None,
4333                    ssn: None,
4334                    username: None,
4335                    passport_number: None,
4336                    license_number: None,
4337                });
4338                let list_view = decrypt_blob_list_view(&key_store, view);
4339                assert_eq!(list_view.name, "My Identity");
4340                assert!(list_view.subtitle.contains("Jane"));
4341                assert!(list_view.subtitle.contains("Doe"));
4342                assert!(matches!(list_view.r#type, CipherListViewType::Identity));
4343                assert!(
4344                    list_view
4345                        .copyable_fields
4346                        .contains(&CopyableCipherFields::IdentityEmail)
4347                );
4348                assert!(
4349                    list_view
4350                        .copyable_fields
4351                        .contains(&CopyableCipherFields::IdentityAddress)
4352                );
4353            }
4354
4355            // --- SecureNote ---
4356            {
4357                let mut view = generate_cipher();
4358                view.r#type = CipherType::SecureNote;
4359                view.login = None;
4360                view.name = "My Note".to_string();
4361                view.notes = Some("secret".to_string());
4362                view.secure_note = Some(SecureNoteView {
4363                    r#type: SecureNoteType::Generic,
4364                });
4365                let list_view = decrypt_blob_list_view(&key_store, view);
4366                assert_eq!(list_view.name, "My Note");
4367                assert_eq!(list_view.subtitle, "");
4368                assert!(matches!(list_view.r#type, CipherListViewType::SecureNote));
4369                assert!(
4370                    list_view
4371                        .copyable_fields
4372                        .contains(&CopyableCipherFields::SecureNotes)
4373                );
4374            }
4375
4376            // --- SshKey ---
4377            {
4378                let mut view = generate_cipher();
4379                view.r#type = CipherType::SshKey;
4380                view.login = None;
4381                view.name = "My SSH".to_string();
4382                view.ssh_key = Some(SshKeyView {
4383                    private_key: "-----BEGIN PRIVATE KEY-----".to_string(),
4384                    public_key: "ssh-ed25519 AAAA".to_string(),
4385                    fingerprint: "SHA256:abcdef".to_string(),
4386                });
4387                let list_view = decrypt_blob_list_view(&key_store, view);
4388                assert_eq!(list_view.name, "My SSH");
4389                assert_eq!(list_view.subtitle, "SHA256:abcdef");
4390                assert!(matches!(list_view.r#type, CipherListViewType::SshKey));
4391                assert!(
4392                    list_view
4393                        .copyable_fields
4394                        .contains(&CopyableCipherFields::SshKey)
4395                );
4396            }
4397
4398            // --- BankAccount ---
4399            {
4400                let mut view = generate_cipher();
4401                view.r#type = CipherType::BankAccount;
4402                view.login = None;
4403                view.name = "My Bank Account".to_string();
4404                view.bank_account = Some(BankAccountView {
4405                    bank_name: Some("Some Bank".to_string()),
4406                    name_on_account: Some("Jane Doe".to_string()),
4407                    account_number: Some("123456".to_string()),
4408                    routing_number: Some("111000025".to_string()),
4409                    branch_number: Some("001".to_string()),
4410                    pin: Some("4321".to_string()),
4411                    swift_code: Some("ABCDEF12".to_string()),
4412                    iban: Some("DE89370400440532013000".to_string()),
4413                    ..Default::default()
4414                });
4415                let list_view = decrypt_blob_list_view(&key_store, view);
4416                assert_eq!(list_view.name, "My Bank Account");
4417                assert_eq!(list_view.subtitle, "Some Bank");
4418                assert_eq!(
4419                    list_view.r#type,
4420                    CipherListViewType::BankAccount(BankAccountListView {
4421                        account_number: Some("123456".to_string()),
4422                        account_type: None,
4423                    })
4424                );
4425                assert_eq!(
4426                    list_view.copyable_fields,
4427                    vec![
4428                        CopyableCipherFields::BankAccountNameOnAccount,
4429                        CopyableCipherFields::BankAccountAccountNumber,
4430                        CopyableCipherFields::BankAccountRoutingNumber,
4431                        CopyableCipherFields::BankAccountBranchNumber,
4432                        CopyableCipherFields::BankAccountPin,
4433                        CopyableCipherFields::BankAccountIban,
4434                        CopyableCipherFields::BankAccountSwift,
4435                    ]
4436                );
4437            }
4438        }
4439
4440        /// A fully-populated `CipherView` for every [`CipherType`], so that every
4441        /// presence-gated `copyable_fields` branch fires.
4442        ///
4443        /// Every optional field that influences `copyable_fields` is set; a new copyable
4444        /// field added to one decryption path but not the other will change one path's
4445        /// output and trip [`copyable_fields_parity_between_legacy_and_blob`].
4446        fn fully_populated_views() -> Vec<(&'static str, CipherView)> {
4447            let with_type = |r#type: CipherType, f: &dyn Fn(&mut CipherView)| {
4448                let mut view = generate_cipher();
4449                view.r#type = r#type;
4450                view.login = None;
4451                f(&mut view);
4452                view
4453            };
4454
4455            vec![
4456                ("Login", base_login_view()),
4457                (
4458                    "Card",
4459                    with_type(CipherType::Card, &|v| {
4460                        v.card = Some(CardView {
4461                            cardholder_name: Some("Jane Doe".to_string()),
4462                            exp_month: Some("12".to_string()),
4463                            exp_year: Some("2030".to_string()),
4464                            code: Some("123".to_string()),
4465                            brand: Some("Visa".to_string()),
4466                            number: Some("4111111111111111".to_string()),
4467                        });
4468                    }),
4469                ),
4470                (
4471                    "Identity",
4472                    with_type(CipherType::Identity, &|v| {
4473                        v.identity = Some(IdentityView {
4474                            title: Some("Mx".to_string()),
4475                            first_name: Some("Jane".to_string()),
4476                            middle_name: Some("Q".to_string()),
4477                            last_name: Some("Doe".to_string()),
4478                            address1: Some("1 Main St".to_string()),
4479                            address2: Some("Apt 2".to_string()),
4480                            address3: Some("Floor 3".to_string()),
4481                            city: Some("Anytown".to_string()),
4482                            state: Some("CA".to_string()),
4483                            postal_code: Some("90210".to_string()),
4484                            country: Some("US".to_string()),
4485                            company: Some("Acme".to_string()),
4486                            email: Some("[email protected]".to_string()),
4487                            phone: Some("555-0100".to_string()),
4488                            ssn: Some("000-00-0000".to_string()),
4489                            username: Some("jane".to_string()),
4490                            passport_number: Some("X1234567".to_string()),
4491                            license_number: Some("D1234567".to_string()),
4492                        });
4493                    }),
4494                ),
4495                (
4496                    "SecureNote",
4497                    with_type(CipherType::SecureNote, &|v| {
4498                        v.notes = Some("a secret note".to_string());
4499                        v.secure_note = Some(SecureNoteView {
4500                            r#type: SecureNoteType::Generic,
4501                        });
4502                    }),
4503                ),
4504                (
4505                    "SshKey",
4506                    with_type(CipherType::SshKey, &|v| {
4507                        v.ssh_key = Some(SshKeyView {
4508                            private_key: "private".to_string(),
4509                            public_key: "public".to_string(),
4510                            fingerprint: "SHA256:abc".to_string(),
4511                        });
4512                    }),
4513                ),
4514                (
4515                    "BankAccount",
4516                    with_type(CipherType::BankAccount, &|v| {
4517                        v.bank_account = Some(BankAccountView {
4518                            bank_name: Some("Some Bank".to_string()),
4519                            name_on_account: Some("Jane Doe".to_string()),
4520                            account_type: Some("Checking".to_string()),
4521                            account_number: Some("123456".to_string()),
4522                            routing_number: Some("111000025".to_string()),
4523                            branch_number: Some("001".to_string()),
4524                            pin: Some("4321".to_string()),
4525                            swift_code: Some("ABCDEF12".to_string()),
4526                            iban: Some("DE89370400440532013000".to_string()),
4527                            bank_contact_phone: Some("555-0199".to_string()),
4528                        });
4529                    }),
4530                ),
4531                (
4532                    "DriversLicense",
4533                    with_type(CipherType::DriversLicense, &|v| {
4534                        v.drivers_license = Some(DriversLicenseView {
4535                            first_name: Some("Jane".to_string()),
4536                            middle_name: Some("Q".to_string()),
4537                            last_name: Some("Doe".to_string()),
4538                            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
4539                            license_number: Some("D1234567".to_string()),
4540                            issuing_country: Some("US".to_string()),
4541                            issuing_state: Some("CA".to_string()),
4542                            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4543                            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
4544                            issuing_authority: Some("DMV".to_string()),
4545                            license_class: Some("C".to_string()),
4546                        });
4547                    }),
4548                ),
4549                (
4550                    "Passport",
4551                    with_type(CipherType::Passport, &|v| {
4552                        v.passport = Some(PassportView {
4553                            surname: Some("Doe".to_string()),
4554                            given_name: Some("Jane".to_string()),
4555                            date_of_birth: chrono::NaiveDate::from_ymd_opt(1990, 1, 1),
4556                            sex: Some("F".to_string()),
4557                            birth_place: Some("Anytown".to_string()),
4558                            nationality: Some("US".to_string()),
4559                            issuing_country: Some("US".to_string()),
4560                            passport_number: Some("X1234567".to_string()),
4561                            passport_type: Some("P".to_string()),
4562                            national_identification_number: Some("000-00-0000".to_string()),
4563                            issuing_authority: Some("State Dept".to_string()),
4564                            issue_date: chrono::NaiveDate::from_ymd_opt(2020, 1, 1),
4565                            expiration_date: chrono::NaiveDate::from_ymd_opt(2030, 1, 1),
4566                        });
4567                    }),
4568                ),
4569            ]
4570        }
4571
4572        /// The legacy field-level path and the blob path independently derive
4573        /// `copyable_fields` — legacy from `Option<EncString>` presence on the encrypted
4574        /// kind, blob from `Option<String>` presence on the decrypted view. They must
4575        /// agree for identical input, or the same cipher renders differently depending on
4576        /// its storage format. This guards every type against drift without hardcoding the
4577        /// expected set per type.
4578        #[test]
4579        fn copyable_fields_parity_between_legacy_and_blob() {
4580            let key_store = make_key_store();
4581
4582            for (label, view) in fully_populated_views() {
4583                let legacy: CipherListView = key_store
4584                    .decrypt(&encrypt_legacy(view.clone(), &key_store))
4585                    .unwrap();
4586                let blob = decrypt_blob_list_view(&key_store, view);
4587
4588                assert_eq!(
4589                    legacy.copyable_fields, blob.copyable_fields,
4590                    "copyable_fields diverged between legacy and blob paths for {label}",
4591                );
4592            }
4593        }
4594
4595        /// Blob path unseals plaintext TOTP; the projection re-encrypts it under the
4596        /// cipher key so [`CipherListView::get_totp_key`] (which decrypts on demand)
4597        /// still returns the original plaintext.
4598        #[test]
4599        fn login_list_view_preserves_totp_round_trip() {
4600            let key_store = make_key_store();
4601            let list_view = decrypt_blob_list_view(&key_store, base_login_view());
4602
4603            match &list_view.r#type {
4604                CipherListViewType::Login(login) => assert!(login.totp.is_some()),
4605                other => panic!("expected Login, got {other:?}"),
4606            }
4607            let totp = list_view.get_totp_key().unwrap();
4608            assert_eq!(totp.as_deref(), Some("otpauth://totp/test?secret=SECRET"));
4609        }
4610
4611        /// `decrypt_list` handles a slice containing both blob and legacy ciphers
4612        #[test]
4613        fn mixed_batch_decrypt_list() {
4614            let key_store = make_key_store();
4615            let blob = encrypt_blob(base_login_view(), &key_store);
4616            let legacy = encrypt_legacy(base_login_view(), &key_store);
4617
4618            let ciphers = vec![blob, legacy];
4619            let views: Vec<CipherListView> = key_store.decrypt_list(&ciphers).unwrap();
4620
4621            assert_eq!(views.len(), 2);
4622            for v in &views {
4623                assert_eq!(v.name, "Test Login");
4624                assert_eq!(v.subtitle, "[email protected]");
4625            }
4626        }
4627
4628        fn decrypt_blob_list_view(
4629            key_store: &KeyStore<KeySlotIds>,
4630            view: CipherView,
4631        ) -> CipherListView {
4632            let cipher = encrypt_blob(view, key_store);
4633            key_store.decrypt(&cipher).unwrap()
4634        }
4635
4636        /// Three attachments whose `EncString`s are sealed under an unrelated
4637        /// key, so they fail to decrypt under any cipher key. The middle one has
4638        /// no wrapped key, marking it an "old" (v1) attachment.
4639        fn failing_attachments() -> Vec<attachment::Attachment> {
4640            let wrong = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4641                SymmetricKeyAlgorithm::Aes256CbcHmac,
4642            ));
4643            let mut ctx = wrong.context();
4644            let mut enc = |s: &str| s.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap();
4645            vec![
4646                attachment::Attachment {
4647                    id: Some("a1".to_string()),
4648                    url: None,
4649                    size: None,
4650                    size_name: None,
4651                    file_name: Some(enc("a1.txt")),
4652                    key: Some(enc("k1")),
4653                },
4654                attachment::Attachment {
4655                    id: Some("a2-old".to_string()),
4656                    url: None,
4657                    size: None,
4658                    size_name: None,
4659                    file_name: Some(enc("a2.txt")),
4660                    key: None,
4661                },
4662                attachment::Attachment {
4663                    id: Some("a3".to_string()),
4664                    url: None,
4665                    size: None,
4666                    size_name: None,
4667                    file_name: Some(enc("a3.txt")),
4668                    key: Some(enc("k3")),
4669                },
4670            ]
4671        }
4672
4673        /// Attachment metrics must agree across paths even when attachments fail
4674        /// to decrypt. The legacy path counts the encrypted server model directly;
4675        /// the blob path routes failures into `attachment_decryption_failures`, so
4676        /// the projection must count those too — otherwise a corrupt attachment
4677        /// makes the same cipher report a different `attachments` count and
4678        /// `has_old_attachments` flag depending on its storage format.
4679        #[test]
4680        fn attachment_metrics_parity_with_failing_attachments() {
4681            let key_store = make_key_store();
4682
4683            let mut legacy = encrypt_legacy(base_login_view(), &key_store);
4684            legacy.attachments = Some(failing_attachments());
4685            let legacy_list: CipherListView = key_store.decrypt(&legacy).unwrap();
4686
4687            let mut blob = encrypt_blob(base_login_view(), &key_store);
4688            blob.attachments = Some(failing_attachments());
4689            let blob_list: CipherListView = key_store.decrypt(&blob).unwrap();
4690
4691            assert_eq!(legacy_list.attachments, 3);
4692            assert!(legacy_list.has_old_attachments);
4693            assert_eq!(blob_list.attachments, legacy_list.attachments);
4694            assert_eq!(
4695                blob_list.has_old_attachments,
4696                legacy_list.has_old_attachments,
4697            );
4698        }
4699    }
4700
4701    // ---------- EncryptMode ----------
4702
4703    mod encrypt_mode {
4704        use bitwarden_crypto::{IdentifyKey, KeyStore};
4705
4706        use super::*;
4707
4708        fn make_key_store() -> KeyStore<KeySlotIds> {
4709            create_test_crypto_with_user_key(SymmetricCryptoKey::make(
4710                SymmetricKeyAlgorithm::Aes256CbcHmac,
4711            ))
4712        }
4713
4714        fn base_login_view() -> CipherView {
4715            let mut view = generate_cipher();
4716            view.name = "Round Trip".to_string();
4717            view.login = Some(LoginView {
4718                username: Some("[email protected]".to_string()),
4719                password: Some("hunter2".to_string()),
4720                password_revision_date: None,
4721                uris: None,
4722                totp: None,
4723                autofill_on_page_load: None,
4724                fido2_credentials: None,
4725            });
4726            view
4727        }
4728
4729        /// Blob variant produces a blob-shaped cipher: sealed `data`, placeholder
4730        /// `name`, and every per-type sensitive field cleared.
4731        #[test]
4732        fn blob_variant_produces_blob_shaped_cipher() {
4733            let key_store = make_key_store();
4734            let mode = EncryptMode::Blob(base_login_view());
4735
4736            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4737
4738            assert!(try_parse_blob(&cipher).is_some());
4739            assert!(cipher.data.is_some());
4740            assert!(cipher.login.is_none());
4741            assert!(cipher.card.is_none());
4742            assert!(cipher.identity.is_none());
4743            assert!(cipher.secure_note.is_none());
4744            assert!(cipher.ssh_key.is_none());
4745            assert!(cipher.bank_account.is_none());
4746            assert!(cipher.fields.is_none());
4747            assert!(cipher.password_history.is_none());
4748            assert!(cipher.notes.is_none());
4749        }
4750
4751        /// Legacy variant produces a legacy-shaped cipher: `data` empty, and the
4752        /// matching per-type field populated.
4753        #[test]
4754        fn legacy_variant_produces_legacy_shaped_cipher() {
4755            let key_store = make_key_store();
4756            let mode = EncryptMode::Legacy(base_login_view());
4757
4758            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4759
4760            assert!(try_parse_blob(&cipher).is_none());
4761            assert!(cipher.data.is_none());
4762            assert!(cipher.login.is_some());
4763            assert!(
4764                cipher.key.is_some(),
4765                "legacy encrypt must always emit a cipher key"
4766            );
4767        }
4768
4769        /// Legacy encryption must wrap the generated cipher key under the wrapping key it is
4770        /// handed, not the view's own `User`/`Organization` slot — key rotation passes a `Local`
4771        /// slot holding the new user key.
4772        #[test]
4773        fn legacy_variant_wraps_cipher_key_under_explicit_key() {
4774            let key_store = make_key_store();
4775            let view = base_login_view();
4776            let mut ctx = key_store.context();
4777
4778            let new_key = ctx.add_local_symmetric_key(SymmetricCryptoKey::make(
4779                SymmetricKeyAlgorithm::Aes256CbcHmac,
4780            ));
4781            let cipher = EncryptMode::Legacy(view.clone())
4782                .encrypt_composite(&mut ctx, new_key)
4783                .unwrap();
4784
4785            // The cipher key unwraps under the explicitly supplied key, and the data it protects
4786            // round-trips.
4787            let decrypted = lenient_decrypt_cipher_view(&cipher, &mut ctx, new_key).unwrap();
4788            assert_eq!(decrypted.name, view.name);
4789            assert_eq!(
4790                decrypted.login.as_ref().unwrap().password,
4791                view.login.as_ref().unwrap().password
4792            );
4793
4794            // It does not unwrap under the view's natural slot.
4795            assert!(
4796                lenient_decrypt_cipher_view(&cipher, &mut ctx, view.key_identifier()).is_err(),
4797                "cipher key must not be wrapped under the view's own slot"
4798            );
4799        }
4800
4801        /// An already-keyed view keeps its cipher key through encryption.
4802        #[test]
4803        fn legacy_variant_preserves_existing_cipher_key() {
4804            let key_store = make_key_store();
4805            let mut view = base_login_view();
4806            let mut ctx = key_store.context();
4807
4808            let _ = view.load_cipher_key_slot(&mut ctx).unwrap();
4809            let original_key = view
4810                .key
4811                .clone()
4812                .expect("load_cipher_key_slot stores the key");
4813
4814            let wrapping_key = ctx.add_local_symmetric_key(SymmetricCryptoKey::make(
4815                SymmetricKeyAlgorithm::Aes256CbcHmac,
4816            ));
4817            let cipher = EncryptMode::Legacy(view)
4818                .encrypt_composite(&mut ctx, wrapping_key)
4819                .unwrap();
4820
4821            let unwrapped = ctx
4822                .unwrap_symmetric_key(wrapping_key, &cipher.key.expect("cipher key is emitted"))
4823                .unwrap();
4824            #[allow(deprecated)]
4825            let unwrapped_raw = ctx.dangerous_get_symmetric_key(unwrapped).unwrap().clone();
4826
4827            assert_eq!(
4828                unwrapped_raw, original_key,
4829                "encryption must wrap the view's existing cipher key, not a new one"
4830            );
4831        }
4832
4833        /// Blob variant round-trips through decryption
4834        #[test]
4835        fn blob_variant_round_trips_through_decrypt() {
4836            let key_store = make_key_store();
4837            let original = base_login_view();
4838            let mode = EncryptMode::Blob(original.clone());
4839
4840            let cipher: Cipher = key_store.encrypt(mode).unwrap();
4841            let restored: CipherView = key_store.decrypt(&cipher).unwrap();
4842
4843            assert_eq!(restored.name, original.name);
4844            let login = restored.login.expect("round-trip should restore login");
4845            assert_eq!(login.username, original.login.as_ref().unwrap().username);
4846            assert_eq!(login.password, original.login.as_ref().unwrap().password);
4847        }
4848
4849        /// `key_identifier` must delegate to the inner view so `encrypt_list`
4850        /// selects the correct scope key.
4851        #[test]
4852        fn key_identifier_delegates_to_inner_view() {
4853            let view = base_login_view();
4854            let expected = view.key_identifier();
4855            let mode = EncryptMode::Blob(view);
4856            assert_eq!(mode.key_identifier(), expected);
4857        }
4858
4859        /// A mixed-batch `encrypt_list` preserves input order and produces a
4860        /// cipher shaped per-variant.
4861        #[test]
4862        fn mixed_batch_encrypt_list_preserves_per_item_shape() {
4863            let key_store = make_key_store();
4864            let mut legacy_view = base_login_view();
4865            legacy_view.name = "Legacy".to_string();
4866            let mut blob_view = base_login_view();
4867            blob_view.name = "Blob".to_string();
4868
4869            let modes = vec![
4870                EncryptMode::Legacy(legacy_view),
4871                EncryptMode::Blob(blob_view),
4872            ];
4873            let ciphers: Vec<Cipher> = key_store.encrypt_list(&modes).unwrap();
4874
4875            assert_eq!(ciphers.len(), 2);
4876            assert!(
4877                try_parse_blob(&ciphers[0]).is_none(),
4878                "first item should be legacy"
4879            );
4880            assert!(
4881                try_parse_blob(&ciphers[1]).is_some(),
4882                "second item should be blob"
4883            );
4884            assert!(ciphers[0].login.is_some());
4885            assert!(ciphers[1].login.is_none());
4886        }
4887    }
4888}