Skip to main content

bitwarden_vault/cipher/
attachment.rs

1use bitwarden_api_api::models::CipherAttachmentModel;
2use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
3use bitwarden_crypto::{
4    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
5    OctetStreamBytes, PrimitiveEncryptable, SymmetricCryptoKey,
6};
7use serde::{Deserialize, Serialize};
8#[cfg(feature = "wasm")]
9use tsify::Tsify;
10
11use super::Cipher;
12use crate::VaultParseError;
13
14/// Cryptographic material for a new attachment, shared by the upgrade and create paths.
15pub(crate) struct AttachmentMaterial {
16    /// Raw attachment key, used to encrypt the attachment contents.
17    pub(crate) key: SymmetricCryptoKey,
18    /// Attachment key wrapped with the cipher key, stored on the attachment record.
19    pub(crate) wrapped_key: EncString,
20    /// File name encrypted with the cipher key.
21    pub(crate) encrypted_file_name: EncString,
22}
23
24#[allow(missing_docs)]
25#[derive(Serialize, Deserialize, Debug, Clone)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
28#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
29pub struct Attachment {
30    pub id: Option<String>,
31    pub url: Option<String>,
32    pub size: Option<String>,
33    /// Readable size, ex: "4.2 KB" or "1.43 GB"
34    pub size_name: Option<String>,
35    pub file_name: Option<EncString>,
36    pub key: Option<EncString>,
37}
38
39impl From<Attachment> for CipherAttachmentModel {
40    fn from(attachment: Attachment) -> Self {
41        Self {
42            file_name: attachment.file_name.map(|f| f.to_string()),
43            key: attachment.key.map(|k| k.to_string()),
44        }
45    }
46}
47
48/// The encryption format an attachment uses, derived from whether it carries its own key.
49#[derive(Debug, PartialEq, Eq)]
50pub(crate) enum AttachmentEncryptionVersion {
51    /// Legacy v1: contents are encrypted directly with the cipher key; no per-attachment key.
52    LegacyNoKeyV1,
53    /// V2: a per-attachment key, wrapped by the cipher key, encrypts the contents.
54    AttachmentKeyV2,
55}
56
57impl Attachment {
58    /// Returns the [`AttachmentEncryptionVersion`] this attachment uses.
59    pub(crate) fn encryption_version(&self) -> AttachmentEncryptionVersion {
60        match self.key {
61            Some(_) => AttachmentEncryptionVersion::AttachmentKeyV2,
62            None => AttachmentEncryptionVersion::LegacyNoKeyV1,
63        }
64    }
65}
66
67#[allow(missing_docs)]
68#[derive(Serialize, Deserialize, Debug, Clone)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
71#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
72pub struct AttachmentView {
73    pub id: Option<String>,
74    pub url: Option<String>,
75    pub size: Option<String>,
76    pub size_name: Option<String>,
77    pub file_name: Option<String>,
78    pub key: Option<EncString>,
79    /// The decrypted attachmentkey in base64 format.
80    ///
81    /// **TEMPORARY FIELD**: This field is a temporary workaround to provide
82    /// decrypted attachment keys to the TypeScript client during the migration
83    /// process. It will be removed once the encryption/decryption logic is
84    /// fully migrated to the SDK.
85    ///
86    /// **Ticket**: <https://bitwarden.atlassian.net/browse/PM-23005>
87    ///
88    /// Do not rely on this field for long-term use.
89    #[cfg(feature = "wasm")]
90    pub decrypted_key: Option<String>,
91}
92
93impl AttachmentView {
94    pub(crate) fn reencrypt_key(
95        &mut self,
96        ctx: &mut KeyStoreContext<KeySlotIds>,
97        old_key: SymmetricKeySlotId,
98        new_key: SymmetricKeySlotId,
99    ) -> Result<(), CryptoError> {
100        if let Some(attachment_key) = &mut self.key {
101            let tmp_attachment_key_id = ctx.unwrap_symmetric_key(old_key, attachment_key)?;
102            *attachment_key = ctx.wrap_symmetric_key(new_key, tmp_attachment_key_id)?;
103        }
104        Ok(())
105    }
106
107    pub(crate) fn reencrypt_keys(
108        attachment_views: &mut Vec<AttachmentView>,
109        ctx: &mut KeyStoreContext<KeySlotIds>,
110        old_key: SymmetricKeySlotId,
111        new_key: SymmetricKeySlotId,
112    ) -> Result<(), CryptoError> {
113        for attachment in attachment_views {
114            attachment.reencrypt_key(ctx, old_key, new_key)?;
115        }
116        Ok(())
117    }
118}
119
120#[allow(missing_docs)]
121#[derive(Serialize, Deserialize, Debug)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
124pub struct AttachmentEncryptResult {
125    pub attachment: Attachment,
126    pub contents: Vec<u8>,
127}
128
129#[allow(missing_docs)]
130pub struct AttachmentFile {
131    pub cipher: Cipher,
132    pub attachment: AttachmentView,
133
134    /// There are three different ways attachments are encrypted.
135    /// 1. UserKey / OrgKey (Contents) - Legacy
136    /// 2. AttachmentKey(Contents) - Pre CipherKey
137    /// 3. CipherKey(AttachmentKey(Contents)) - Current
138    pub contents: EncString,
139}
140
141#[allow(missing_docs)]
142pub struct AttachmentFileView<'a> {
143    pub cipher: Cipher,
144    pub attachment: AttachmentView,
145    pub contents: &'a [u8],
146}
147
148impl IdentifyKey<SymmetricKeySlotId> for AttachmentFileView<'_> {
149    fn key_identifier(&self) -> SymmetricKeySlotId {
150        self.cipher.key_identifier()
151    }
152}
153impl IdentifyKey<SymmetricKeySlotId> for AttachmentFile {
154    fn key_identifier(&self) -> SymmetricKeySlotId {
155        self.cipher.key_identifier()
156    }
157}
158
159impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, AttachmentEncryptResult>
160    for AttachmentFileView<'_>
161{
162    fn encrypt_composite(
163        &self,
164        ctx: &mut KeyStoreContext<KeySlotIds>,
165        key: SymmetricKeySlotId,
166    ) -> Result<AttachmentEncryptResult, CryptoError> {
167        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
168
169        let mut attachment = self.attachment.clone();
170
171        // Because this is a new attachment, we have to generate a key for it, encrypt the contents
172        // with it, and then encrypt the key with the cipher key
173        let attachment_key = ctx.generate_symmetric_key();
174        let encrypted_contents =
175            OctetStreamBytes::from(self.contents).encrypt(ctx, attachment_key)?;
176        attachment.key = Some(ctx.wrap_symmetric_key(ciphers_key, attachment_key)?);
177
178        let contents = encrypted_contents.to_buffer()?;
179
180        // Once we have the encrypted contents, we can set the size of the attachment
181        attachment.size = Some(contents.len().to_string());
182        attachment.size_name = Some(size_name(contents.len()));
183
184        Ok(AttachmentEncryptResult {
185            attachment: attachment.encrypt_composite(ctx, ciphers_key)?,
186            contents,
187        })
188    }
189}
190
191fn size_name(size: usize) -> String {
192    let units = ["Bytes", "KB", "MB", "GB", "TB"];
193    let size = size as f64;
194    let unit = (size.ln() / 1024_f64.ln()).floor() as usize;
195    let size = size / 1024_f64.powi(unit as i32);
196
197    let size_round = (size * 10.0_f64).round() as usize as f64 / 10.0_f64;
198    format!("{} {}", size_round, units[unit])
199}
200
201impl Decryptable<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
202    fn decrypt(
203        &self,
204        ctx: &mut KeyStoreContext<KeySlotIds>,
205        key: SymmetricKeySlotId,
206    ) -> Result<Vec<u8>, CryptoError> {
207        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key).map_err(|e| {
208            tracing::warn!(
209                attachment_id = ?self.attachment.id,
210                cipher_id = ?self.cipher.id,
211                has_cipher_key = self.cipher.key.is_some(),
212                error = %e,
213                "Failed to decrypt cipher key for attachment"
214            );
215            e
216        })?;
217
218        // Version 2 or 3, `AttachmentKey` or `CipherKey(AttachmentKey)`
219        if let Some(attachment_key) = &self.attachment.key {
220            let content_key = ctx
221                .unwrap_symmetric_key(ciphers_key, attachment_key)
222                .map_err(|e| {
223                    tracing::warn!(
224                        attachment_id = ?self.attachment.id,
225                        cipher_id = ?self.cipher.id,
226                        error = %e,
227                        "Failed to unwrap attachment key (v2/v3)"
228                    );
229                    e
230                })?;
231            self.contents.decrypt(ctx, content_key).map_err(|e| {
232                tracing::warn!(
233                    attachment_id = ?self.attachment.id,
234                    cipher_id = ?self.cipher.id,
235                    error = %e,
236                    "Failed to decrypt attachment contents with attachment key (v2/v3)"
237                );
238                e
239            })
240        } else {
241            // Legacy attachment version 1, use user/org key
242            self.contents.decrypt(ctx, key).map_err(|e| {
243                tracing::warn!(
244                    attachment_id = ?self.attachment.id,
245                    cipher_id = ?self.cipher.id,
246                    error = %e,
247                    "Failed to decrypt attachment contents with user/org key (legacy v1)"
248                );
249                e
250            })
251        }
252    }
253}
254
255impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentView {
256    fn encrypt_composite(
257        &self,
258        ctx: &mut KeyStoreContext<KeySlotIds>,
259        key: SymmetricKeySlotId,
260    ) -> Result<Attachment, CryptoError> {
261        Ok(Attachment {
262            id: self.id.clone(),
263            url: self.url.clone(),
264            size: self.size.clone(),
265            size_name: self.size_name.clone(),
266            file_name: self.file_name.encrypt(ctx, key)?,
267            key: self.key.clone(),
268        })
269    }
270}
271
272impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
273    fn decrypt(
274        &self,
275        ctx: &mut KeyStoreContext<KeySlotIds>,
276        key: SymmetricKeySlotId,
277    ) -> Result<AttachmentView, CryptoError> {
278        // Decrypt the file name or return an error if decryption fails
279        let file_name = self.file_name.decrypt(ctx, key)?;
280
281        #[cfg(feature = "wasm")]
282        let decrypted_key = if let Some(attachment_key) = &self.key {
283            let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
284
285            #[allow(deprecated)]
286            let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
287
288            Some(actual_key.to_base64())
289        } else {
290            None
291        };
292
293        Ok(AttachmentView {
294            id: self.id.clone(),
295            url: self.url.clone(),
296            size: self.size.clone(),
297            size_name: self.size_name.clone(),
298            file_name,
299            key: self.key.clone(),
300            #[cfg(feature = "wasm")]
301            decrypted_key: decrypted_key.map(|k| k.to_string()),
302        })
303    }
304}
305
306/// Decrypts a list of attachments, separating successful decryptions from failures.
307///
308/// Returns a tuple of (successful_attachments, failed_attachments).
309pub(crate) fn decrypt_attachments_with_failures(
310    attachments: &[Attachment],
311    ctx: &mut KeyStoreContext<KeySlotIds>,
312    key: SymmetricKeySlotId,
313) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
314    let mut successes = Vec::new();
315    let mut failures = Vec::new();
316
317    for attachment in attachments {
318        match attachment.decrypt(ctx, key) {
319            Ok(decrypted) => successes.push(decrypted),
320            Err(e) => {
321                tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
322                failures.push(AttachmentView {
323                    id: attachment.id.clone(),
324                    url: attachment.url.clone(),
325                    size: attachment.size.clone(),
326                    size_name: attachment.size_name.clone(),
327                    file_name: None,
328                    key: attachment.key.clone(),
329                    #[cfg(feature = "wasm")]
330                    decrypted_key: None,
331                });
332            }
333        }
334    }
335
336    (successes, failures)
337}
338
339impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
340    type Error = VaultParseError;
341
342    fn try_from(
343        attachment: bitwarden_api_api::models::AttachmentResponseModel,
344    ) -> Result<Self, Self::Error> {
345        Ok(Self {
346            id: attachment.id,
347            url: attachment.url,
348            size: attachment.size,
349            size_name: attachment.size_name,
350            file_name: EncString::try_from_optional(attachment.file_name)?,
351            key: EncString::try_from_optional(attachment.key)?,
352        })
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use bitwarden_core::key_management::create_test_crypto_with_user_key;
359    use bitwarden_crypto::{EncString, SymmetricCryptoKey};
360    use bitwarden_encoding::B64;
361
362    use crate::{
363        AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
364        cipher::cipher::{CipherRepromptType, CipherType},
365    };
366
367    #[test]
368    fn test_size_name_conversions() {
369        assert_eq!(super::size_name(0), "0 Bytes");
370        assert_eq!(super::size_name(19), "19 Bytes");
371        assert_eq!(super::size_name(1024), "1 KB");
372        assert_eq!(super::size_name(1570), "1.5 KB");
373        assert_eq!(super::size_name(1024 * 1024), "1 MB");
374        assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
375        assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
376        assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
377    }
378
379    #[test]
380    fn test_encrypt_attachment() {
381        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
382        let key_store = create_test_crypto_with_user_key(user_key);
383
384        let attachment = AttachmentView {
385            id: None,
386            url: None,
387            size: Some("100".into()),
388            size_name: Some("100 Bytes".into()),
389            file_name: Some("Test.txt".into()),
390            key: None,
391            #[cfg(feature = "wasm")]
392            decrypted_key: None,
393        };
394
395        let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
396
397        let attachment_file = AttachmentFileView {
398            cipher: Cipher {
399                id: None,
400                organization_id: None,
401                folder_id: None,
402                collection_ids: Vec::new(),
403                key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
404                name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
405                notes: None,
406                r#type: CipherType::Login,
407                login: None,
408                identity: None,
409                card: None,
410                secure_note: None,
411                ssh_key: None,
412                bank_account: None,
413                drivers_license: None,
414                passport: None,
415                favorite: false,
416                reprompt: CipherRepromptType::None,
417                organization_use_totp: false,
418                edit: true,
419                permissions: None,
420                view_password: true,
421                local_data: None,
422                attachments: None,
423                fields: None,
424                password_history: None,
425                creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
426                deleted_date: None,
427                revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
428                archived_date: None,
429                data: None,
430            },
431            attachment,
432            contents: contents.as_slice(),
433        };
434
435        let result = key_store.encrypt(attachment_file).unwrap();
436
437        assert_eq!(result.contents.len(), 161);
438        assert_eq!(result.attachment.size, Some("161".into()));
439        assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
440    }
441
442    #[test]
443    fn test_attachment_key() {
444        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
445        let key_store = create_test_crypto_with_user_key(user_key);
446
447        let attachment = AttachmentView {
448            id: None,
449            url: None,
450            size: Some("161".into()),
451            size_name: Some("161 Bytes".into()),
452            file_name: Some("Test.txt".into()),
453            key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
454            #[cfg(feature = "wasm")]
455            decrypted_key: None,
456        };
457
458        let cipher  = Cipher {
459            id: None,
460            organization_id: None,
461            folder_id: None,
462            collection_ids: Vec::new(),
463            key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
464            name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
465            notes: None,
466            r#type: CipherType::Login,
467            login: None,
468            identity: None,
469            card: None,
470            secure_note: None,
471            ssh_key: None,
472            bank_account: None,
473            drivers_license: None,
474            passport: None,
475            favorite: false,
476            reprompt: CipherRepromptType::None,
477            organization_use_totp: false,
478            edit: true,
479            permissions: None,
480            view_password: true,
481            local_data: None,
482            attachments: None,
483            fields: None,
484            password_history: None,
485            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
486            deleted_date: None,
487            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
488            archived_date: None,
489            data: None,
490        };
491
492        let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
493        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
494
495        let dec = key_store
496            .decrypt(&AttachmentFile {
497                cipher,
498                attachment,
499                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
500            })
501            .unwrap();
502
503        assert_eq!(dec, original.as_bytes());
504    }
505
506    #[test]
507    fn test_attachment_without_key() {
508        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
509        let key_store = create_test_crypto_with_user_key(user_key);
510
511        let attachment = AttachmentView {
512            id: None,
513            url: None,
514            size: Some("161".into()),
515            size_name: Some("161 Bytes".into()),
516            file_name: Some("Test.txt".into()),
517            key: None,
518            #[cfg(feature = "wasm")]
519            decrypted_key: None,
520        };
521
522        let cipher  = Cipher {
523            id: None,
524            organization_id: None,
525            folder_id: None,
526            collection_ids: Vec::new(),
527            key: None,
528            name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
529            notes: None,
530            r#type: CipherType::Login,
531            login: None,
532            identity: None,
533            card: None,
534            secure_note: None,
535            ssh_key: None,
536            bank_account: None,
537            drivers_license: None,
538            passport: None,
539            favorite: false,
540            reprompt: CipherRepromptType::None,
541            organization_use_totp: false,
542            edit: true,
543            permissions: None,
544            view_password: true,
545            local_data: None,
546            attachments: None,
547            fields: None,
548            password_history: None,
549            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
550            deleted_date: None,
551            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
552            archived_date: None,
553            data: None,
554        };
555
556        let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
557        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
558
559        let dec = key_store
560            .decrypt(&AttachmentFile {
561                cipher,
562                attachment,
563                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
564            })
565            .unwrap();
566
567        assert_eq!(dec, original.as_bytes());
568    }
569}