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