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