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