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