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