bitwarden_vault/cipher/
attachment.rs

1use bitwarden_api_api::models::CipherAttachmentModel;
2use bitwarden_core::key_management::{KeyIds, SymmetricKeyId};
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<KeyIds>,
68        old_key: SymmetricKeyId,
69        new_key: SymmetricKeyId,
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<KeyIds>,
81        old_key: SymmetricKeyId,
82        new_key: SymmetricKeyId,
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<SymmetricKeyId> for AttachmentFileView<'_> {
120    fn key_identifier(&self) -> SymmetricKeyId {
121        self.cipher.key_identifier()
122    }
123}
124impl IdentifyKey<SymmetricKeyId> for AttachmentFile {
125    fn key_identifier(&self) -> SymmetricKeyId {
126        self.cipher.key_identifier()
127    }
128}
129
130impl CompositeEncryptable<KeyIds, SymmetricKeyId, AttachmentEncryptResult>
131    for AttachmentFileView<'_>
132{
133    fn encrypt_composite(
134        &self,
135        ctx: &mut KeyStoreContext<KeyIds>,
136        key: SymmetricKeyId,
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<KeyIds, SymmetricKeyId, Vec<u8>> for AttachmentFile {
173    fn decrypt(
174        &self,
175        ctx: &mut KeyStoreContext<KeyIds>,
176        key: SymmetricKeyId,
177    ) -> Result<Vec<u8>, CryptoError> {
178        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
179
180        // Version 2 or 3, `AttachmentKey` or `CipherKey(AttachmentKey)`
181        if let Some(attachment_key) = &self.attachment.key {
182            let content_key = ctx.unwrap_symmetric_key(ciphers_key, attachment_key)?;
183            self.contents.decrypt(ctx, content_key)
184        } else {
185            // Legacy attachment version 1, use user/org key
186            self.contents.decrypt(ctx, key)
187        }
188    }
189}
190
191impl CompositeEncryptable<KeyIds, SymmetricKeyId, Attachment> for AttachmentView {
192    fn encrypt_composite(
193        &self,
194        ctx: &mut KeyStoreContext<KeyIds>,
195        key: SymmetricKeyId,
196    ) -> Result<Attachment, CryptoError> {
197        Ok(Attachment {
198            id: self.id.clone(),
199            url: self.url.clone(),
200            size: self.size.clone(),
201            size_name: self.size_name.clone(),
202            file_name: self.file_name.encrypt(ctx, key)?,
203            key: self.key.clone(),
204        })
205    }
206}
207
208impl Decryptable<KeyIds, SymmetricKeyId, AttachmentView> for Attachment {
209    fn decrypt(
210        &self,
211        ctx: &mut KeyStoreContext<KeyIds>,
212        key: SymmetricKeyId,
213    ) -> Result<AttachmentView, CryptoError> {
214        #[cfg(feature = "wasm")]
215        let decrypted_key = if let Some(attachment_key) = &self.key {
216            let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
217
218            #[allow(deprecated)]
219            let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
220
221            Some(actual_key.to_base64())
222        } else {
223            None
224        };
225
226        Ok(AttachmentView {
227            id: self.id.clone(),
228            url: self.url.clone(),
229            size: self.size.clone(),
230            size_name: self.size_name.clone(),
231            file_name: self.file_name.decrypt(ctx, key)?,
232            key: self.key.clone(),
233            #[cfg(feature = "wasm")]
234            decrypted_key: decrypted_key.map(|k| k.to_string()),
235        })
236    }
237}
238
239impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
240    type Error = VaultParseError;
241
242    fn try_from(
243        attachment: bitwarden_api_api::models::AttachmentResponseModel,
244    ) -> Result<Self, Self::Error> {
245        Ok(Self {
246            id: attachment.id,
247            url: attachment.url,
248            size: attachment.size,
249            size_name: attachment.size_name,
250            file_name: EncString::try_from_optional(attachment.file_name)?,
251            key: EncString::try_from_optional(attachment.key)?,
252        })
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use bitwarden_core::key_management::create_test_crypto_with_user_key;
259    use bitwarden_crypto::{EncString, SymmetricCryptoKey};
260    use bitwarden_encoding::B64;
261
262    use crate::{
263        AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
264        cipher::cipher::{CipherRepromptType, CipherType},
265    };
266
267    #[test]
268    fn test_size_name_conversions() {
269        assert_eq!(super::size_name(0), "0 Bytes");
270        assert_eq!(super::size_name(19), "19 Bytes");
271        assert_eq!(super::size_name(1024), "1 KB");
272        assert_eq!(super::size_name(1570), "1.5 KB");
273        assert_eq!(super::size_name(1024 * 1024), "1 MB");
274        assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
275        assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
276        assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
277    }
278
279    #[test]
280    fn test_encrypt_attachment() {
281        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
282        let key_store = create_test_crypto_with_user_key(user_key);
283
284        let attachment = AttachmentView {
285            id: None,
286            url: None,
287            size: Some("100".into()),
288            size_name: Some("100 Bytes".into()),
289            file_name: Some("Test.txt".into()),
290            key: None,
291            decrypted_key: None,
292        };
293
294        let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
295
296        let attachment_file = AttachmentFileView {
297            cipher: Cipher {
298                id: None,
299                organization_id: None,
300                folder_id: None,
301                collection_ids: Vec::new(),
302                key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
303                name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
304                notes: None,
305                r#type: CipherType::Login,
306                login: None,
307                identity: None,
308                card: None,
309                secure_note: None,
310                ssh_key: None,
311                favorite: false,
312                reprompt: CipherRepromptType::None,
313                organization_use_totp: false,
314                edit: true,
315                permissions: None,
316                view_password: true,
317                local_data: None,
318                attachments: None,
319                fields: None,
320                password_history: None,
321                creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
322                deleted_date: None,
323                revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
324                archived_date: None,
325            },
326            attachment,
327            contents: contents.as_slice(),
328        };
329
330        let result = key_store.encrypt(attachment_file).unwrap();
331
332        assert_eq!(result.contents.len(), 161);
333        assert_eq!(result.attachment.size, Some("161".into()));
334        assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
335    }
336
337    #[test]
338    fn test_attachment_key() {
339        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
340        let key_store = create_test_crypto_with_user_key(user_key);
341
342        let attachment = AttachmentView {
343            id: None,
344            url: None,
345            size: Some("161".into()),
346            size_name: Some("161 Bytes".into()),
347            file_name: Some("Test.txt".into()),
348            key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
349            decrypted_key: None,
350        };
351
352        let cipher  = Cipher {
353            id: None,
354            organization_id: None,
355            folder_id: None,
356            collection_ids: Vec::new(),
357            key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
358            name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
359            notes: None,
360            r#type: CipherType::Login,
361            login: None,
362            identity: None,
363            card: None,
364            secure_note: None,
365            ssh_key: None,
366            favorite: false,
367            reprompt: CipherRepromptType::None,
368            organization_use_totp: false,
369            edit: true,
370            permissions: None,
371            view_password: true,
372            local_data: None,
373            attachments: None,
374            fields: None,
375            password_history: None,
376            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
377            deleted_date: None,
378            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
379            archived_date: None,
380        };
381
382        let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
383        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
384
385        let dec = key_store
386            .decrypt(&AttachmentFile {
387                cipher,
388                attachment,
389                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
390            })
391            .unwrap();
392
393        assert_eq!(dec, original.as_bytes());
394    }
395
396    #[test]
397    fn test_attachment_without_key() {
398        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
399        let key_store = create_test_crypto_with_user_key(user_key);
400
401        let attachment = AttachmentView {
402            id: None,
403            url: None,
404            size: Some("161".into()),
405            size_name: Some("161 Bytes".into()),
406            file_name: Some("Test.txt".into()),
407            key: None,
408            decrypted_key: None,
409        };
410
411        let cipher  = Cipher {
412            id: None,
413            organization_id: None,
414            folder_id: None,
415            collection_ids: Vec::new(),
416            key: None,
417            name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
418            notes: None,
419            r#type: CipherType::Login,
420            login: None,
421            identity: None,
422            card: None,
423            secure_note: None,
424            ssh_key: None,
425            favorite: false,
426            reprompt: CipherRepromptType::None,
427            organization_use_totp: false,
428            edit: true,
429            permissions: None,
430            view_password: true,
431            local_data: None,
432            attachments: None,
433            fields: None,
434            password_history: None,
435            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
436            deleted_date: None,
437            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
438            archived_date: None,
439        };
440
441        let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
442        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
443
444        let dec = key_store
445            .decrypt(&AttachmentFile {
446                cipher,
447                attachment,
448                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
449            })
450            .unwrap();
451
452        assert_eq!(dec, original.as_bytes());
453    }
454}