Skip to main content

bitwarden_vault/cipher/blob/
encryption.rs

1use bitwarden_core::key_management::KeySlotIds;
2use bitwarden_crypto::{
3    CompositeEncryptable, CryptoError, Decryptable, IdentifyKey, KeyStoreContext,
4    PrimitiveEncryptable,
5};
6use thiserror::Error;
7
8use super::{CipherBlob, CipherBlobLatest, SealedCipherBlob, SealedCipherBlobError};
9use crate::cipher::{
10    attachment,
11    cipher::{Cipher, CipherView},
12};
13
14#[derive(Debug, Error)]
15pub(crate) enum BlobEncryptionError {
16    #[error(transparent)]
17    Crypto(#[from] CryptoError),
18    #[error(transparent)]
19    SealedBlob(#[from] SealedCipherBlobError),
20    #[error("Cipher does not contain blob data")]
21    NoBlobData,
22}
23
24/// Returns `true` if the cipher's `data` field contains a valid sealed blob.
25pub(crate) fn is_blob_encrypted(cipher: &Cipher) -> bool {
26    cipher
27        .data
28        .as_ref()
29        .is_some_and(|s| SealedCipherBlob::from_opaque_string(s).is_ok())
30}
31
32/// Returns `true` if the cipher is not blob-encrypted (i.e. uses legacy field-level encryption).
33pub(crate) fn is_legacy_cipher(cipher: &Cipher) -> bool {
34    !is_blob_encrypted(cipher)
35}
36
37/// Seals a `CipherView` into an opaque blob string.
38fn seal_cipher(
39    view: &CipherView,
40    ctx: &mut KeyStoreContext<KeySlotIds>,
41) -> Result<String, BlobEncryptionError> {
42    let outer_key = view.key_identifier();
43    let cipher_key = Cipher::decrypt_cipher_key(ctx, outer_key, &view.key)?;
44
45    let blob = CipherBlobLatest::from_cipher_view(view, ctx, cipher_key)?;
46    let versioned: CipherBlob = blob.into();
47    let sealed = SealedCipherBlob::seal(versioned, &cipher_key, ctx)?;
48    Ok(sealed.to_opaque_string()?)
49}
50
51/// Unseals a cipher's blob data, returning the latest blob version.
52fn unseal_cipher(
53    cipher: &Cipher,
54    ctx: &mut KeyStoreContext<KeySlotIds>,
55) -> Result<CipherBlobLatest, BlobEncryptionError> {
56    let outer_key = cipher.key_identifier();
57    let cipher_key = Cipher::decrypt_cipher_key(ctx, outer_key, &cipher.key)?;
58
59    let data = cipher
60        .data
61        .as_ref()
62        .ok_or(BlobEncryptionError::NoBlobData)?;
63    let sealed = SealedCipherBlob::from_opaque_string(data)?;
64    let blob = sealed.unseal(&cipher_key, ctx)?;
65
66    match blob {
67        CipherBlob::CipherBlobV1(v1) => Ok(v1),
68    }
69}
70
71/// Encrypts a `CipherView` into a blob-encrypted `Cipher`
72///
73/// Generates a cipher key if missing, seals the sensitive data into a single blob,
74/// and encrypts attachments and local data separately.
75pub(crate) fn encrypt_blob_cipher(
76    view: &mut CipherView,
77    ctx: &mut KeyStoreContext<KeySlotIds>,
78) -> Result<Cipher, BlobEncryptionError> {
79    if view.key.is_none() {
80        view.generate_cipher_key(ctx, view.key_identifier())?;
81    }
82
83    let outer_key = view.key_identifier();
84    let cipher_key = Cipher::decrypt_cipher_key(ctx, outer_key, &view.key)?;
85
86    let sealed_string = seal_cipher(view, ctx)?;
87
88    let attachments = view.attachments.encrypt_composite(ctx, cipher_key)?;
89    let local_data = view.local_data.encrypt_composite(ctx, cipher_key)?;
90
91    // TODO: Remove this field once the server no longer requires it
92    let name = "".encrypt(ctx, cipher_key)?;
93
94    Ok(Cipher {
95        // Metadata
96        id: view.id,
97        organization_id: view.organization_id,
98        folder_id: view.folder_id,
99        collection_ids: view.collection_ids.clone(),
100        key: view.key.clone(),
101        r#type: view.r#type,
102        favorite: view.favorite,
103        reprompt: view.reprompt,
104        organization_use_totp: view.organization_use_totp,
105        edit: view.edit,
106        permissions: view.permissions,
107        view_password: view.view_password,
108        creation_date: view.creation_date,
109        deleted_date: view.deleted_date,
110        revision_date: view.revision_date,
111        archived_date: view.archived_date,
112
113        // Sensitive data
114        data: Some(sealed_string),
115        attachments,
116        local_data,
117
118        // Obsolete fields — sensitive data lives in the blob
119        // TODO: Remove `name` once the server no longer requires it
120        name,
121        notes: None,
122        login: None,
123        identity: None,
124        card: None,
125        secure_note: None,
126        ssh_key: None,
127        bank_account: None,
128        drivers_license: None,
129        passport: None,
130        fields: None,
131        password_history: None,
132    })
133}
134
135/// Decrypts a blob-encrypted `Cipher` into a `CipherView`.
136///
137/// Unseals the blob data, decrypts attachments and local data, then applies
138/// the blob content fields onto the view.
139pub(crate) fn decrypt_blob_cipher(
140    cipher: &Cipher,
141    ctx: &mut KeyStoreContext<KeySlotIds>,
142) -> Result<CipherView, BlobEncryptionError> {
143    let outer_key = cipher.key_identifier();
144    let cipher_key = Cipher::decrypt_cipher_key(ctx, outer_key, &cipher.key)?;
145
146    let blob = unseal_cipher(cipher, ctx)?;
147
148    let (attachments, attachment_decryption_failures) =
149        attachment::decrypt_attachments_with_failures(
150            cipher.attachments.as_deref().unwrap_or_default(),
151            ctx,
152            cipher_key,
153        );
154
155    let local_data = cipher.local_data.decrypt(ctx, cipher_key).ok().flatten();
156
157    let mut view = CipherView {
158        // Metadata
159        id: cipher.id,
160        organization_id: cipher.organization_id,
161        folder_id: cipher.folder_id,
162        collection_ids: cipher.collection_ids.clone(),
163        key: cipher.key.clone(),
164        r#type: cipher.r#type,
165        favorite: cipher.favorite,
166        reprompt: cipher.reprompt,
167        organization_use_totp: cipher.organization_use_totp,
168        edit: cipher.edit,
169        permissions: cipher.permissions,
170        view_password: cipher.view_password,
171        creation_date: cipher.creation_date,
172        deleted_date: cipher.deleted_date,
173        revision_date: cipher.revision_date,
174        archived_date: cipher.archived_date,
175
176        // Sensitive data — decrypted separately from the blob
177        attachments: Some(attachments),
178        attachment_decryption_failures: Some(attachment_decryption_failures),
179        local_data,
180
181        // Populated by blob.apply_to_cipher_view() below
182        name: String::new(),
183        notes: None,
184        login: None,
185        identity: None,
186        card: None,
187        secure_note: None,
188        ssh_key: None,
189        bank_account: None,
190        drivers_license: None,
191        passport: None,
192        fields: None,
193        password_history: None,
194    };
195
196    blob.apply_to_cipher_view(&mut view, ctx, cipher_key)?;
197
198    Ok(view)
199}
200
201#[cfg(test)]
202mod tests {
203    use bitwarden_crypto::IdentifyKey;
204    use uuid::Uuid;
205
206    use super::*;
207    use crate::{
208        cipher::{
209            bank_account::BankAccountView,
210            blob::conversions::test_support::{create_shell_cipher_view, create_test_key_store},
211            card::CardView,
212            cipher::{CipherId, CipherRepromptType, CipherType},
213            field::{FieldType, FieldView},
214            identity::IdentityView,
215            login::LoginView,
216            secure_note::{SecureNoteType, SecureNoteView},
217            ssh_key::SshKeyView,
218        },
219        password_history::PasswordHistoryView,
220    };
221
222    fn make_test_cipher_with_data(
223        ctx: &mut KeyStoreContext<KeySlotIds>,
224        data: Option<String>,
225    ) -> Cipher {
226        let name = "test"
227            .encrypt(
228                ctx,
229                bitwarden_core::key_management::SymmetricKeySlotId::User,
230            )
231            .unwrap();
232        Cipher {
233            id: None,
234            organization_id: None,
235            folder_id: None,
236            collection_ids: vec![],
237            key: None,
238            name,
239            notes: None,
240            r#type: CipherType::SecureNote,
241            login: None,
242            identity: None,
243            card: None,
244            secure_note: None,
245            ssh_key: None,
246            bank_account: None,
247            drivers_license: None,
248            passport: None,
249            favorite: false,
250            reprompt: CipherRepromptType::None,
251            organization_use_totp: false,
252            edit: true,
253            permissions: None,
254            view_password: true,
255            local_data: None,
256            attachments: None,
257            fields: None,
258            password_history: None,
259            creation_date: chrono::Utc::now(),
260            deleted_date: None,
261            revision_date: chrono::Utc::now(),
262            archived_date: None,
263            data,
264        }
265    }
266
267    #[test]
268    fn test_is_blob_encrypted_true() {
269        let (key_store, _) = create_test_key_store();
270        let mut ctx = key_store.context_mut();
271
272        let mut view = create_shell_cipher_view(CipherType::SecureNote);
273        view.name = "Blob Test".to_string();
274        view.secure_note = Some(SecureNoteView {
275            r#type: SecureNoteType::Generic,
276        });
277
278        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
279        assert!(is_blob_encrypted(&cipher));
280    }
281
282    #[test]
283    fn test_is_blob_encrypted_false_no_data() {
284        let (key_store, _) = create_test_key_store();
285        let mut ctx = key_store.context_mut();
286        let cipher = make_test_cipher_with_data(&mut ctx, None);
287        assert!(!is_blob_encrypted(&cipher));
288    }
289
290    #[test]
291    fn test_is_blob_encrypted_false_invalid_data() {
292        let (key_store, _) = create_test_key_store();
293        let mut ctx = key_store.context_mut();
294        let cipher = make_test_cipher_with_data(&mut ctx, Some("not a valid blob".to_string()));
295        assert!(!is_blob_encrypted(&cipher));
296    }
297
298    #[test]
299    fn test_seal_unseal_round_trip() {
300        let (key_store, _) = create_test_key_store();
301        let mut ctx = key_store.context_mut();
302
303        let mut view = create_shell_cipher_view(CipherType::SecureNote);
304        view.name = "Round Trip".to_string();
305        view.notes = Some("Some notes".to_string());
306        view.secure_note = Some(SecureNoteView {
307            r#type: SecureNoteType::Generic,
308        });
309        view.generate_cipher_key(&mut ctx, view.key_identifier())
310            .unwrap();
311
312        let sealed_string = seal_cipher(&view, &mut ctx).unwrap();
313
314        let mut cipher = make_test_cipher_with_data(&mut ctx, Some(sealed_string));
315        cipher.key = view.key.clone();
316
317        let blob = unseal_cipher(&cipher, &mut ctx).unwrap();
318        assert_eq!(blob.name, "Round Trip");
319        assert_eq!(blob.notes, Some("Some notes".to_string()));
320    }
321
322    #[test]
323    fn test_encrypt_blob_cipher_sets_data() {
324        let (key_store, _) = create_test_key_store();
325        let mut ctx = key_store.context_mut();
326
327        let mut view = create_shell_cipher_view(CipherType::SecureNote);
328        view.name = "Has Data".to_string();
329        view.secure_note = Some(SecureNoteView {
330            r#type: SecureNoteType::Generic,
331        });
332
333        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
334        assert!(cipher.data.is_some());
335    }
336
337    #[test]
338    fn test_encrypt_blob_cipher_clears_legacy_fields() {
339        let (key_store, _) = create_test_key_store();
340        let mut ctx = key_store.context_mut();
341
342        let mut view = create_shell_cipher_view(CipherType::Login);
343        view.name = "Login".to_string();
344        view.login = Some(LoginView {
345            username: Some("user".to_string()),
346            password: Some("pass".to_string()),
347            password_revision_date: None,
348            uris: None,
349            totp: None,
350            autofill_on_page_load: None,
351            fido2_credentials: None,
352        });
353
354        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
355        assert!(cipher.login.is_none());
356        assert!(cipher.card.is_none());
357        assert!(cipher.identity.is_none());
358        assert!(cipher.secure_note.is_none());
359        assert!(cipher.ssh_key.is_none());
360        assert!(cipher.bank_account.is_none());
361        assert!(cipher.notes.is_none());
362        assert!(cipher.fields.is_none());
363        assert!(cipher.password_history.is_none());
364    }
365
366    #[test]
367    fn test_encrypt_blob_cipher_generates_key() {
368        let (key_store, _) = create_test_key_store();
369        let mut ctx = key_store.context_mut();
370
371        let mut view = create_shell_cipher_view(CipherType::SecureNote);
372        view.secure_note = Some(SecureNoteView {
373            r#type: SecureNoteType::Generic,
374        });
375        assert!(view.key.is_none());
376
377        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
378        assert!(cipher.key.is_some());
379        assert!(view.key.is_some());
380    }
381
382    #[test]
383    fn test_encrypt_blob_cipher_preserves_metadata() {
384        let (key_store, _) = create_test_key_store();
385        let mut ctx = key_store.context_mut();
386
387        let cipher_id = CipherId::new(Uuid::new_v4());
388        let mut view = create_shell_cipher_view(CipherType::SecureNote);
389        view.id = Some(cipher_id);
390        view.favorite = true;
391        view.reprompt = CipherRepromptType::Password;
392        view.name = "Metadata Test".to_string();
393        view.secure_note = Some(SecureNoteView {
394            r#type: SecureNoteType::Generic,
395        });
396
397        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
398        assert_eq!(cipher.id, Some(cipher_id));
399        assert!(cipher.favorite);
400        assert_eq!(cipher.reprompt, CipherRepromptType::Password);
401        assert_eq!(cipher.r#type, CipherType::SecureNote);
402        assert_eq!(cipher.creation_date, view.creation_date);
403        assert_eq!(cipher.revision_date, view.revision_date);
404    }
405
406    #[test]
407    fn test_encrypt_blob_cipher_each_type() {
408        let (key_store, _) = create_test_key_store();
409
410        // Login
411        {
412            let mut ctx = key_store.context_mut();
413            let mut view = create_shell_cipher_view(CipherType::Login);
414            view.name = "Login".to_string();
415            view.login = Some(LoginView {
416                username: Some("user".to_string()),
417                password: None,
418                password_revision_date: None,
419                uris: None,
420                totp: None,
421                autofill_on_page_load: None,
422                fido2_credentials: None,
423            });
424            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
425        }
426
427        // Card
428        {
429            let mut ctx = key_store.context_mut();
430            let mut view = create_shell_cipher_view(CipherType::Card);
431            view.name = "Card".to_string();
432            view.card = Some(CardView {
433                cardholder_name: Some("John".to_string()),
434                exp_month: None,
435                exp_year: None,
436                code: None,
437                brand: None,
438                number: None,
439            });
440            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
441        }
442
443        // Identity
444        {
445            let mut ctx = key_store.context_mut();
446            let mut view = create_shell_cipher_view(CipherType::Identity);
447            view.name = "Identity".to_string();
448            view.identity = Some(IdentityView {
449                title: None,
450                first_name: Some("Jane".to_string()),
451                middle_name: None,
452                last_name: None,
453                address1: None,
454                address2: None,
455                address3: None,
456                city: None,
457                state: None,
458                postal_code: None,
459                country: None,
460                company: None,
461                email: None,
462                phone: None,
463                ssn: None,
464                username: None,
465                passport_number: None,
466                license_number: None,
467            });
468            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
469        }
470
471        // SecureNote
472        {
473            let mut ctx = key_store.context_mut();
474            let mut view = create_shell_cipher_view(CipherType::SecureNote);
475            view.name = "Note".to_string();
476            view.secure_note = Some(SecureNoteView {
477                r#type: SecureNoteType::Generic,
478            });
479            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
480        }
481
482        // SshKey
483        {
484            let mut ctx = key_store.context_mut();
485            let mut view = create_shell_cipher_view(CipherType::SshKey);
486            view.name = "SSH".to_string();
487            view.ssh_key = Some(SshKeyView {
488                private_key: "private".to_string(),
489                public_key: "public".to_string(),
490                fingerprint: "fingerprint".to_string(),
491            });
492            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
493        }
494
495        // BankAccount
496        {
497            let mut ctx = key_store.context_mut();
498            let mut view = create_shell_cipher_view(CipherType::BankAccount);
499            view.name = "Bank".to_string();
500            view.bank_account = Some(BankAccountView {
501                bank_name: Some("Bank".to_string()),
502                name_on_account: None,
503                account_type: None,
504                account_number: None,
505                routing_number: None,
506                branch_number: None,
507                pin: None,
508                swift_code: None,
509                iban: None,
510                bank_contact_phone: None,
511            });
512            assert!(encrypt_blob_cipher(&mut view, &mut ctx).is_ok());
513        }
514    }
515
516    #[test]
517    fn test_end_to_end_round_trip() {
518        let (key_store, _) = create_test_key_store();
519        let mut ctx = key_store.context_mut();
520
521        let mut view = create_shell_cipher_view(CipherType::Login);
522        view.name = "My Login".to_string();
523        view.notes = Some("Secret notes".to_string());
524        view.login = Some(LoginView {
525            username: Some("[email protected]".to_string()),
526            password: Some("p@ssw0rd".to_string()),
527            password_revision_date: None,
528            uris: None,
529            totp: None,
530            autofill_on_page_load: None,
531            fido2_credentials: None,
532        });
533        view.fields = Some(vec![FieldView {
534            name: Some("custom".to_string()),
535            value: Some("field-value".to_string()),
536            r#type: FieldType::Text,
537            linked_id: None,
538        }]);
539        let history_date = chrono::Utc::now();
540        view.password_history = Some(vec![PasswordHistoryView {
541            password: "old-p@ssw0rd".to_string(),
542            last_used_date: history_date,
543        }]);
544
545        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
546        assert!(is_blob_encrypted(&cipher));
547        assert!(!is_legacy_cipher(&cipher));
548
549        let restored = decrypt_blob_cipher(&cipher, &mut ctx).unwrap();
550
551        assert_eq!(restored.name, "My Login");
552        assert_eq!(restored.notes, Some("Secret notes".to_string()));
553        let login = restored.login.unwrap();
554        assert_eq!(login.username, Some("[email protected]".to_string()));
555        assert_eq!(login.password, Some("p@ssw0rd".to_string()));
556
557        let fields = restored.fields.unwrap();
558        assert_eq!(fields.len(), 1);
559        assert_eq!(fields[0].name, Some("custom".to_string()));
560        assert_eq!(fields[0].value, Some("field-value".to_string()));
561        assert_eq!(fields[0].r#type, FieldType::Text);
562
563        let history = restored.password_history.unwrap();
564        assert_eq!(history.len(), 1);
565        assert_eq!(history[0].password, "old-p@ssw0rd");
566        assert_eq!(history[0].last_used_date, history_date);
567    }
568
569    #[test]
570    fn test_decrypt_blob_cipher() {
571        let (key_store, _) = create_test_key_store();
572        let mut ctx = key_store.context_mut();
573
574        let mut view = create_shell_cipher_view(CipherType::Card);
575        view.name = "My Card".to_string();
576        view.notes = Some("Card notes".to_string());
577        view.card = Some(CardView {
578            cardholder_name: Some("John Doe".to_string()),
579            exp_month: Some("12".to_string()),
580            exp_year: Some("2030".to_string()),
581            code: Some("123".to_string()),
582            brand: Some("Visa".to_string()),
583            number: Some("4111111111111111".to_string()),
584        });
585
586        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
587        let restored = decrypt_blob_cipher(&cipher, &mut ctx).unwrap();
588
589        assert_eq!(restored.name, "My Card");
590        assert_eq!(restored.notes, Some("Card notes".to_string()));
591        let card = restored.card.unwrap();
592        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
593        assert_eq!(card.number, Some("4111111111111111".to_string()));
594        assert_eq!(card.code, Some("123".to_string()));
595        assert_eq!(card.brand, Some("Visa".to_string()));
596    }
597
598    #[test]
599    fn test_decrypt_blob_cipher_preserves_metadata() {
600        let (key_store, _) = create_test_key_store();
601        let mut ctx = key_store.context_mut();
602
603        let cipher_id = CipherId::new(Uuid::new_v4());
604        let mut view = create_shell_cipher_view(CipherType::SecureNote);
605        view.id = Some(cipher_id);
606        view.favorite = true;
607        view.reprompt = CipherRepromptType::Password;
608        view.organization_use_totp = true;
609        view.edit = false;
610        view.view_password = false;
611        view.name = "Metadata".to_string();
612        view.secure_note = Some(SecureNoteView {
613            r#type: SecureNoteType::Generic,
614        });
615        let creation_date = view.creation_date;
616        let revision_date = view.revision_date;
617
618        let cipher = encrypt_blob_cipher(&mut view, &mut ctx).unwrap();
619        let restored = decrypt_blob_cipher(&cipher, &mut ctx).unwrap();
620
621        assert_eq!(restored.id, Some(cipher_id));
622        assert!(restored.favorite);
623        assert_eq!(restored.reprompt, CipherRepromptType::Password);
624        assert!(restored.organization_use_totp);
625        assert!(!restored.edit);
626        assert!(!restored.view_password);
627        assert_eq!(restored.r#type, CipherType::SecureNote);
628        assert_eq!(restored.creation_date, creation_date);
629        assert_eq!(restored.revision_date, revision_date);
630        assert!(restored.key.is_some());
631    }
632
633    #[test]
634    fn test_decrypt_blob_cipher_no_blob_data() {
635        let (key_store, _) = create_test_key_store();
636        let mut ctx = key_store.context_mut();
637
638        let cipher = make_test_cipher_with_data(&mut ctx, None);
639        let result = decrypt_blob_cipher(&cipher, &mut ctx);
640
641        assert!(result.is_err());
642        assert!(
643            matches!(&result.unwrap_err(), BlobEncryptionError::NoBlobData),
644            "Expected NoBlobData error"
645        );
646    }
647}