bitwarden_vault/cipher/
attachment.rs

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