Skip to main content

bitwarden_vault/cipher/blob/
encryption.rs

1use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
2use bitwarden_crypto::{
3    CompositeEncryptable, CryptoError, Decryptable, IdentifyKey, KeyStoreContext,
4};
5use bitwarden_logging::instrument;
6use thiserror::Error;
7
8use super::{CipherBlob, CipherBlobLatest, SealedCipherBlob, SealedCipherBlobError};
9use crate::cipher::{
10    attachment,
11    cipher::{Cipher, CipherRepromptType, CipherType, CipherView},
12};
13
14/// Errors produced while sealing or unsealing a blob-format cipher.
15#[derive(Debug, Error)]
16pub enum BlobEncryptionError {
17    /// A cryptographic primitive (wrap, unwrap, encrypt, decrypt) failed.
18    #[error(transparent)]
19    Crypto(#[from] CryptoError),
20    /// The sealed blob container could not be encoded or decoded.
21    #[error(transparent)]
22    SealedBlob(#[from] SealedCipherBlobError),
23}
24
25/// Maps the blob module's error type onto [`CryptoError`]. Format and envelope
26/// errors collapse to [`CryptoError::Decrypt`]; the specific cause is preserved
27/// in the log.
28impl From<BlobEncryptionError> for CryptoError {
29    fn from(err: BlobEncryptionError) -> Self {
30        tracing::warn!(error = %err, error_debug = ?err, "blob operation failed");
31        match err {
32            BlobEncryptionError::Crypto(c) => c,
33            BlobEncryptionError::SealedBlob(_) => CryptoError::Decrypt,
34        }
35    }
36}
37
38/// Seals a `CipherView` into an opaque blob string under the given `cipher_key` slot.
39/// The caller is responsible for loading the key slot before calling (e.g. via
40/// `CipherView::load_cipher_key_slot`); this avoids allocating a duplicate slot.
41fn seal_cipher(
42    view: &CipherView,
43    ctx: &mut KeyStoreContext<KeySlotIds>,
44    cipher_key: SymmetricKeySlotId,
45) -> Result<String, BlobEncryptionError> {
46    let blob = CipherBlobLatest::from_cipher_view(view)?;
47    seal_blob_content(blob, cipher_key, ctx)
48}
49
50/// Seals a constructed `CipherBlobLatest` under `cipher_key`, returning the
51/// opaque string form. Shared by all `CipherBlobLatest` producers so they
52/// don't each re-implement the versioned-enum wrap + COSE seal + base64 chain.
53fn seal_blob_content(
54    blob: CipherBlobLatest,
55    cipher_key: SymmetricKeySlotId,
56    ctx: &mut KeyStoreContext<KeySlotIds>,
57) -> Result<String, BlobEncryptionError> {
58    let versioned: CipherBlob = blob.into();
59    let sealed = SealedCipherBlob::seal(versioned, &cipher_key, ctx)?;
60    Ok(sealed.to_opaque_string()?)
61}
62
63/// Returns the parsed [`SealedCipherBlob`] if `cipher.data` holds one. Returns
64/// `None` for legacy ciphers (missing or unparseable `data`).
65pub(crate) fn try_parse_blob(cipher: &Cipher) -> Option<SealedCipherBlob> {
66    let data = cipher.data.as_deref()?;
67    SealedCipherBlob::from_opaque_string(data).ok()
68}
69
70/// Encrypts a `CipherView` into a blob-encrypted `Cipher`.
71///
72/// Generates a cipher key if missing, seals the sensitive data into a single blob,
73/// and encrypts attachments and local data separately. The outer wrapping key is
74/// derived from `view.key_identifier()`; see
75/// [`encrypt_blob_cipher_with_wrapping_key`] for the rotation case where the
76/// caller supplies an explicit wrapping key.
77pub(crate) fn encrypt_blob_cipher(
78    view: &mut CipherView,
79    ctx: &mut KeyStoreContext<KeySlotIds>,
80) -> Result<Cipher, BlobEncryptionError> {
81    let wrapping_key = view.key_identifier();
82    encrypt_blob_cipher_with_wrapping_key(view, ctx, wrapping_key)
83}
84
85/// Variant of [`encrypt_blob_cipher`] that accepts an explicit outer wrapping
86/// key. Used by key rotation, where the new user/org key is installed under a
87/// `Local` slot id — `key_identifier()` would resolve to the original
88/// `User`/`Organization` slot and wrap the cipher key under the old key.
89pub(crate) fn encrypt_blob_cipher_with_wrapping_key(
90    view: &mut CipherView,
91    ctx: &mut KeyStoreContext<KeySlotIds>,
92    wrapping_key: SymmetricKeySlotId,
93) -> Result<Cipher, BlobEncryptionError> {
94    // Fail closed: a restricted (partial) view has all secret fields stripped; re-encrypting it
95    // would overwrite the item's secrets with empty values. See `decrypt_restricted_cipher_view`.
96    if view.partial {
97        return Err(BlobEncryptionError::Crypto(
98            CryptoError::EncryptRestrictedView,
99        ));
100    }
101
102    let cipher_key = view.load_cipher_key_slot(ctx)?;
103
104    let sealed_string = seal_cipher(view, ctx, cipher_key)?;
105
106    let attachments = view.attachments.encrypt_composite(ctx, cipher_key)?;
107    let local_data = view.local_data.encrypt_composite(ctx, cipher_key)?;
108
109    Ok(Cipher {
110        partial_data: None,
111        // Metadata
112        id: view.id,
113        organization_id: view.organization_id,
114        folder_id: view.folder_id,
115        collection_ids: view.collection_ids.clone(),
116        key: Some(ctx.wrap_symmetric_key(wrapping_key, cipher_key)?),
117        r#type: view.r#type,
118        favorite: view.favorite,
119        reprompt: view.reprompt,
120        organization_use_totp: view.organization_use_totp,
121        edit: view.edit,
122        permissions: view.permissions,
123        view_password: view.view_password,
124        creation_date: view.creation_date,
125        deleted_date: view.deleted_date,
126        revision_date: view.revision_date,
127        archived_date: view.archived_date,
128
129        // Sensitive data
130        data: Some(sealed_string),
131        attachments,
132        local_data,
133
134        // Obsolete fields — sensitive data lives in the blob
135        name: None,
136        notes: None,
137        login: None,
138        identity: None,
139        card: None,
140        secure_note: None,
141        ssh_key: None,
142        bank_account: None,
143        drivers_license: None,
144        passport: None,
145        fields: None,
146        password_history: None,
147    })
148}
149
150/// Decrypts a pre-parsed blob-encrypted `Cipher` into a `CipherView`. Callers
151/// should obtain the [`SealedCipherBlob`] via [`try_parse_blob`].
152///
153/// `wrapping_key` is the outer key under which the cipher's CEK is wrapped —
154/// usually the user/organization key, but during key rotation it can be a
155/// `Local` slot containing the new user key.
156#[instrument(err, fields(cipher_id = ?cipher.id, org_id = ?cipher.organization_id))]
157pub(crate) fn decrypt_blob_cipher(
158    cipher: &Cipher,
159    sealed: &SealedCipherBlob,
160    ctx: &mut KeyStoreContext<KeySlotIds>,
161    wrapping_key: SymmetricKeySlotId,
162) -> Result<CipherView, BlobEncryptionError> {
163    let cipher_key = Cipher::decrypt_cipher_key(ctx, wrapping_key, &cipher.key)?;
164
165    let CipherBlob::CipherBlobV1(blob) = sealed.unseal(&cipher_key, ctx)?;
166
167    let (attachments, attachment_decryption_failures) =
168        attachment::decrypt_attachments_with_failures(
169            cipher.attachments.as_deref().unwrap_or_default(),
170            ctx,
171            cipher_key,
172        );
173
174    let local_data = cipher.local_data.decrypt(ctx, cipher_key).ok().flatten();
175
176    let mut view = CipherView {
177        partial: false,
178        // Metadata
179        id: cipher.id,
180        organization_id: cipher.organization_id,
181        folder_id: cipher.folder_id,
182        collection_ids: cipher.collection_ids.clone(),
183        key: if cipher.key.is_some() {
184            #[allow(deprecated)]
185            Some(ctx.dangerous_get_symmetric_key(cipher_key)?.clone())
186        } else {
187            None
188        },
189        r#type: cipher.r#type,
190        favorite: cipher.favorite,
191        reprompt: cipher.reprompt,
192        organization_use_totp: cipher.organization_use_totp,
193        edit: cipher.edit,
194        permissions: cipher.permissions,
195        view_password: cipher.view_password,
196        creation_date: cipher.creation_date,
197        deleted_date: cipher.deleted_date,
198        revision_date: cipher.revision_date,
199        archived_date: cipher.archived_date,
200
201        // Sensitive data — decrypted separately from the blob
202        attachments: Some(attachments),
203        attachment_decryption_failures: Some(attachment_decryption_failures),
204        local_data,
205
206        // Populated by blob.apply_to_cipher_view() below
207        name: String::new(),
208        notes: None,
209        login: None,
210        identity: None,
211        card: None,
212        secure_note: None,
213        ssh_key: None,
214        bank_account: None,
215        drivers_license: None,
216        passport: None,
217        fields: None,
218        password_history: None,
219    };
220
221    blob.apply_to_cipher_view(&mut view)?;
222
223    Ok(view)
224}
225
226/// Item Send payloads: the sealed blob is the entire `data` string of an Item Send, sealed
227/// directly under the send key. It has no outer cipher key and no metadata; only the blob
228/// contents (name, notes, type data, fields, password history) round-trip.
229///
230/// ```text
231/// CipherView --seal_blob_for_item_sends(send key)--> SendItem.data (opaque string)
232/// SendItem.data --unseal_blob_for_item_sends(send key)--> CipherView (metadata defaulted)
233/// ```
234impl CipherView {
235    /// Seals the sensitive contents of this view into an opaque blob string under `key`.
236    pub fn seal_blob_for_item_sends(
237        &self,
238        ctx: &mut KeyStoreContext<KeySlotIds>,
239        key: SymmetricKeySlotId,
240    ) -> Result<String, CryptoError> {
241        // Fail closed: a restricted view has its secrets stripped.
242        if self.partial {
243            return Err(CryptoError::EncryptRestrictedView);
244        }
245
246        let blob = CipherBlobLatest::from_cipher_view(self)?;
247        Ok(seal_blob_content(blob, key, ctx)?)
248    }
249
250    /// Unseals a blob produced by [`CipherView::seal_blob_for_item_sends`]. Metadata absent
251    /// from the blob (ids, dates, flags) is set to defaults; the type follows the blob contents.
252    pub fn unseal_blob_for_item_sends(
253        data: &str,
254        ctx: &mut KeyStoreContext<KeySlotIds>,
255        key: SymmetricKeySlotId,
256    ) -> Result<CipherView, CryptoError> {
257        let sealed =
258            SealedCipherBlob::from_opaque_string(data).map_err(BlobEncryptionError::from)?;
259        let CipherBlob::CipherBlobV1(blob) = sealed
260            .unseal(&key, ctx)
261            .map_err(BlobEncryptionError::from)?;
262
263        let mut view = CipherView {
264            partial: false,
265            id: None,
266            organization_id: None,
267            folder_id: None,
268            collection_ids: Vec::new(),
269            key: None,
270            name: String::new(),
271            notes: None,
272            // Overwritten from the blob type data below.
273            r#type: CipherType::Login,
274            login: None,
275            identity: None,
276            card: None,
277            secure_note: None,
278            ssh_key: None,
279            bank_account: None,
280            drivers_license: None,
281            passport: None,
282            favorite: false,
283            reprompt: CipherRepromptType::None,
284            organization_use_totp: false,
285            edit: false,
286            permissions: None,
287            view_password: true,
288            local_data: None,
289            attachments: None,
290            attachment_decryption_failures: None,
291            fields: None,
292            password_history: None,
293            creation_date: Default::default(),
294            deleted_date: None,
295            revision_date: Default::default(),
296            archived_date: None,
297        };
298        blob.apply_to_cipher_view(&mut view)?;
299
300        Ok(view)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use bitwarden_crypto::{IdentifyKey, PrimitiveEncryptable};
307    use uuid::Uuid;
308
309    use super::*;
310    use crate::{
311        cipher::{
312            bank_account::BankAccountView,
313            blob::conversions::test_support::{create_shell_cipher_view, create_test_key_store},
314            card::CardView,
315            cipher::{CipherId, CipherRepromptType, CipherType},
316            field::{FieldType, FieldView},
317            identity::IdentityView,
318            login::LoginView,
319            secure_note::{SecureNoteType, SecureNoteView},
320            ssh_key::SshKeyView,
321        },
322        password_history::PasswordHistoryView,
323    };
324
325    fn make_test_cipher_with_data(
326        ctx: &mut KeyStoreContext<KeySlotIds>,
327        data: Option<String>,
328    ) -> Cipher {
329        let name = "test"
330            .encrypt(
331                ctx,
332                bitwarden_core::key_management::SymmetricKeySlotId::User,
333            )
334            .unwrap();
335        Cipher {
336            partial_data: None,
337            id: None,
338            organization_id: None,
339            folder_id: None,
340            collection_ids: vec![],
341            key: None,
342            name: Some(name),
343            notes: None,
344            r#type: CipherType::SecureNote,
345            login: None,
346            identity: None,
347            card: None,
348            secure_note: None,
349            ssh_key: None,
350            bank_account: None,
351            drivers_license: None,
352            passport: None,
353            favorite: false,
354            reprompt: CipherRepromptType::None,
355            organization_use_totp: false,
356            edit: true,
357            permissions: None,
358            view_password: true,
359            local_data: None,
360            attachments: None,
361            fields: None,
362            password_history: None,
363            creation_date: chrono::Utc::now(),
364            deleted_date: None,
365            revision_date: chrono::Utc::now(),
366            archived_date: None,
367            data,
368        }
369    }
370
371    #[test]
372    fn test_try_parse_blob_returns_some_after_encrypt() {
373        let (key_store, _) = create_test_key_store();
374        let mut ctx = key_store.context_mut();
375
376        let mut view = create_shell_cipher_view(CipherType::SecureNote);
377        view.name = "Blob Test".to_string();
378        view.secure_note = Some(SecureNoteView {
379            r#type: SecureNoteType::Generic,
380        });
381
382        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
383        assert!(try_parse_blob(&cipher).is_some());
384    }
385
386    #[test]
387    fn test_seal_unseal_round_trip() {
388        let (key_store, _) = create_test_key_store();
389        let mut ctx = key_store.context_mut();
390
391        let mut view = create_shell_cipher_view(CipherType::SecureNote);
392        view.name = "Round Trip".to_string();
393        view.notes = Some("Some notes".to_string());
394        view.secure_note = Some(SecureNoteView {
395            r#type: SecureNoteType::Generic,
396        });
397        let cipher_key = view.load_cipher_key_slot(&mut ctx).unwrap();
398
399        let sealed_string = seal_cipher(&view, &mut ctx, cipher_key).unwrap();
400
401        let mut cipher = make_test_cipher_with_data(&mut ctx, Some(sealed_string));
402        if let Some(key) = &view.key {
403            let slot = ctx.add_local_symmetric_key(key.clone());
404            cipher.key = Some(ctx.wrap_symmetric_key(view.key_identifier(), slot).unwrap());
405        }
406
407        let view = decrypt_blob_cipher(
408            &cipher,
409            &try_parse_blob(&cipher).unwrap(),
410            &mut ctx,
411            cipher.key_identifier(),
412        )
413        .unwrap();
414        assert_eq!(view.name, "Round Trip");
415        assert_eq!(view.notes, Some("Some notes".to_string()));
416    }
417
418    #[test]
419    fn test_encrypt_blob_cipher_sets_data() {
420        let (key_store, _) = create_test_key_store();
421        let mut ctx = key_store.context_mut();
422
423        let mut view = create_shell_cipher_view(CipherType::SecureNote);
424        view.name = "Has Data".to_string();
425        view.secure_note = Some(SecureNoteView {
426            r#type: SecureNoteType::Generic,
427        });
428
429        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
430        assert!(cipher.data.is_some());
431    }
432
433    #[test]
434    fn test_encrypt_blob_cipher_clears_legacy_fields() {
435        let (key_store, _) = create_test_key_store();
436        let mut ctx = key_store.context_mut();
437
438        let mut view = create_shell_cipher_view(CipherType::Login);
439        view.name = "Login".to_string();
440        view.login = Some(LoginView {
441            username: Some("user".to_string()),
442            password: Some("pass".to_string()),
443            password_revision_date: None,
444            uris: None,
445            totp: None,
446            autofill_on_page_load: None,
447            fido2_credentials: None,
448        });
449
450        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
451        assert!(cipher.login.is_none());
452        assert!(cipher.card.is_none());
453        assert!(cipher.identity.is_none());
454        assert!(cipher.secure_note.is_none());
455        assert!(cipher.ssh_key.is_none());
456        assert!(cipher.bank_account.is_none());
457        assert!(cipher.notes.is_none());
458        assert!(cipher.fields.is_none());
459        assert!(cipher.password_history.is_none());
460    }
461
462    #[test]
463    fn test_encrypt_blob_cipher_generates_key() {
464        let (key_store, _) = create_test_key_store();
465        let mut ctx = key_store.context_mut();
466
467        let mut view = create_shell_cipher_view(CipherType::SecureNote);
468        view.secure_note = Some(SecureNoteView {
469            r#type: SecureNoteType::Generic,
470        });
471        assert!(view.key.is_none());
472
473        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
474        assert!(cipher.key.is_some());
475        assert!(view.key.is_some());
476    }
477
478    #[test]
479    fn test_encrypt_blob_cipher_preserves_metadata() {
480        let (key_store, _) = create_test_key_store();
481        let mut ctx = key_store.context_mut();
482
483        let cipher_id = CipherId::new(Uuid::new_v4());
484        let mut view = create_shell_cipher_view(CipherType::SecureNote);
485        view.id = Some(cipher_id);
486        view.favorite = true;
487        view.reprompt = CipherRepromptType::Password;
488        view.name = "Metadata Test".to_string();
489        view.secure_note = Some(SecureNoteView {
490            r#type: SecureNoteType::Generic,
491        });
492
493        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
494        assert_eq!(cipher.id, Some(cipher_id));
495        assert!(cipher.favorite);
496        assert_eq!(cipher.reprompt, CipherRepromptType::Password);
497        assert_eq!(cipher.r#type, CipherType::SecureNote);
498        assert_eq!(cipher.creation_date, view.creation_date);
499        assert_eq!(cipher.revision_date, view.revision_date);
500    }
501
502    #[test]
503    fn test_encrypt_blob_cipher_each_type() {
504        let (key_store, _) = create_test_key_store();
505
506        // Login
507        {
508            let mut ctx = key_store.context_mut();
509            let mut view = create_shell_cipher_view(CipherType::Login);
510            view.name = "Login".to_string();
511            view.login = Some(LoginView {
512                username: Some("user".to_string()),
513                password: None,
514                password_revision_date: None,
515                uris: None,
516                totp: None,
517                autofill_on_page_load: None,
518                fido2_credentials: None,
519            });
520            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
521        }
522
523        // Card
524        {
525            let mut ctx = key_store.context_mut();
526            let mut view = create_shell_cipher_view(CipherType::Card);
527            view.name = "Card".to_string();
528            view.card = Some(CardView {
529                cardholder_name: Some("John".to_string()),
530                exp_month: None,
531                exp_year: None,
532                code: None,
533                brand: None,
534                number: None,
535            });
536            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
537        }
538
539        // Identity
540        {
541            let mut ctx = key_store.context_mut();
542            let mut view = create_shell_cipher_view(CipherType::Identity);
543            view.name = "Identity".to_string();
544            view.identity = Some(IdentityView {
545                title: None,
546                first_name: Some("Jane".to_string()),
547                middle_name: None,
548                last_name: None,
549                address1: None,
550                address2: None,
551                address3: None,
552                city: None,
553                state: None,
554                postal_code: None,
555                country: None,
556                company: None,
557                email: None,
558                phone: None,
559                ssn: None,
560                username: None,
561                passport_number: None,
562                license_number: None,
563            });
564            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
565        }
566
567        // SecureNote
568        {
569            let mut ctx = key_store.context_mut();
570            let mut view = create_shell_cipher_view(CipherType::SecureNote);
571            view.name = "Note".to_string();
572            view.secure_note = Some(SecureNoteView {
573                r#type: SecureNoteType::Generic,
574            });
575            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
576        }
577
578        // SshKey
579        {
580            let mut ctx = key_store.context_mut();
581            let mut view = create_shell_cipher_view(CipherType::SshKey);
582            view.name = "SSH".to_string();
583            view.ssh_key = Some(SshKeyView {
584                private_key: "private".to_string(),
585                public_key: "public".to_string(),
586                fingerprint: "fingerprint".to_string(),
587            });
588            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
589        }
590
591        // BankAccount
592        {
593            let mut ctx = key_store.context_mut();
594            let mut view = create_shell_cipher_view(CipherType::BankAccount);
595            view.name = "Bank".to_string();
596            view.bank_account = Some(BankAccountView {
597                bank_name: Some("Bank".to_string()),
598                name_on_account: None,
599                account_type: None,
600                account_number: None,
601                routing_number: None,
602                branch_number: None,
603                pin: None,
604                swift_code: None,
605                iban: None,
606                bank_contact_phone: None,
607            });
608            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
609        }
610    }
611
612    #[test]
613    fn test_end_to_end_round_trip() {
614        let (key_store, _) = create_test_key_store();
615        let mut ctx = key_store.context_mut();
616
617        let mut view = create_shell_cipher_view(CipherType::Login);
618        view.name = "My Login".to_string();
619        view.notes = Some("Secret notes".to_string());
620        view.login = Some(LoginView {
621            username: Some("[email protected]".to_string()),
622            password: Some("p@ssw0rd".to_string()),
623            password_revision_date: None,
624            uris: None,
625            totp: None,
626            autofill_on_page_load: None,
627            fido2_credentials: None,
628        });
629        view.fields = Some(vec![FieldView {
630            name: Some("custom".to_string()),
631            value: Some("field-value".to_string()),
632            r#type: FieldType::Text,
633            linked_id: None,
634        }]);
635        let history_date = chrono::Utc::now();
636        view.password_history = Some(vec![PasswordHistoryView {
637            password: "old-p@ssw0rd".to_string(),
638            last_used_date: history_date,
639        }]);
640
641        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
642        assert!(try_parse_blob(&cipher).is_some());
643
644        let restored = decrypt_blob_cipher(
645            &cipher,
646            &try_parse_blob(&cipher).unwrap(),
647            &mut ctx,
648            cipher.key_identifier(),
649        )
650        .unwrap();
651
652        assert_eq!(restored.name, "My Login");
653        assert_eq!(restored.notes, Some("Secret notes".to_string()));
654        let login = restored.login.unwrap();
655        assert_eq!(login.username, Some("[email protected]".to_string()));
656        assert_eq!(login.password, Some("p@ssw0rd".to_string()));
657
658        let fields = restored.fields.unwrap();
659        assert_eq!(fields.len(), 1);
660        assert_eq!(fields[0].name, Some("custom".to_string()));
661        assert_eq!(fields[0].value, Some("field-value".to_string()));
662        assert_eq!(fields[0].r#type, FieldType::Text);
663
664        let history = restored.password_history.unwrap();
665        assert_eq!(history.len(), 1);
666        assert_eq!(history[0].password, "old-p@ssw0rd");
667        assert_eq!(history[0].last_used_date, history_date);
668    }
669
670    #[test]
671    fn test_decrypt_blob_cipher() {
672        let (key_store, _) = create_test_key_store();
673        let mut ctx = key_store.context_mut();
674
675        let mut view = create_shell_cipher_view(CipherType::Card);
676        view.name = "My Card".to_string();
677        view.notes = Some("Card notes".to_string());
678        view.card = Some(CardView {
679            cardholder_name: Some("John Doe".to_string()),
680            exp_month: Some("12".to_string()),
681            exp_year: Some("2030".to_string()),
682            code: Some("123".to_string()),
683            brand: Some("Visa".to_string()),
684            number: Some("4111111111111111".to_string()),
685        });
686
687        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
688        let restored = decrypt_blob_cipher(
689            &cipher,
690            &try_parse_blob(&cipher).unwrap(),
691            &mut ctx,
692            cipher.key_identifier(),
693        )
694        .unwrap();
695
696        assert_eq!(restored.name, "My Card");
697        assert_eq!(restored.notes, Some("Card notes".to_string()));
698        let card = restored.card.unwrap();
699        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
700        assert_eq!(card.number, Some("4111111111111111".to_string()));
701        assert_eq!(card.code, Some("123".to_string()));
702        assert_eq!(card.brand, Some("Visa".to_string()));
703    }
704
705    #[test]
706    fn test_decrypt_blob_cipher_preserves_metadata() {
707        let (key_store, _) = create_test_key_store();
708        let mut ctx = key_store.context_mut();
709
710        let cipher_id = CipherId::new(Uuid::new_v4());
711        let mut view = create_shell_cipher_view(CipherType::SecureNote);
712        view.id = Some(cipher_id);
713        view.favorite = true;
714        view.reprompt = CipherRepromptType::Password;
715        view.organization_use_totp = true;
716        view.edit = false;
717        view.view_password = false;
718        view.name = "Metadata".to_string();
719        view.secure_note = Some(SecureNoteView {
720            r#type: SecureNoteType::Generic,
721        });
722        let creation_date = view.creation_date;
723        let revision_date = view.revision_date;
724
725        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
726        let restored = decrypt_blob_cipher(
727            &cipher,
728            &try_parse_blob(&cipher).unwrap(),
729            &mut ctx,
730            cipher.key_identifier(),
731        )
732        .unwrap();
733
734        assert_eq!(restored.id, Some(cipher_id));
735        assert!(restored.favorite);
736        assert_eq!(restored.reprompt, CipherRepromptType::Password);
737        assert!(restored.organization_use_totp);
738        assert!(!restored.edit);
739        assert!(!restored.view_password);
740        assert_eq!(restored.r#type, CipherType::SecureNote);
741        assert_eq!(restored.creation_date, creation_date);
742        assert_eq!(restored.revision_date, revision_date);
743        assert!(restored.key.is_some());
744    }
745
746    #[test]
747    fn test_try_parse_blob_returns_none_for_legacy() {
748        let (key_store, _) = create_test_key_store();
749        let mut ctx = key_store.context_mut();
750
751        let cipher = make_test_cipher_with_data(&mut ctx, None);
752        assert!(try_parse_blob(&cipher).is_none());
753
754        let cipher = make_test_cipher_with_data(&mut ctx, Some("not a blob".to_string()));
755        assert!(try_parse_blob(&cipher).is_none());
756    }
757}