bitwarden_vault/cipher/
attachment.rs

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