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 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 #[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 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 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 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 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 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 let file_name = self.file_name.decrypt(ctx, key)?;
216
217 #[cfg(feature = "wasm")]
218 let decrypted_key = if let Some(attachment_key) = &self.key {
219 let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
220
221 #[allow(deprecated)]
222 let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
223
224 Some(actual_key.to_base64())
225 } else {
226 None
227 };
228
229 Ok(AttachmentView {
230 id: self.id.clone(),
231 url: self.url.clone(),
232 size: self.size.clone(),
233 size_name: self.size_name.clone(),
234 file_name,
235 key: self.key.clone(),
236 #[cfg(feature = "wasm")]
237 decrypted_key: decrypted_key.map(|k| k.to_string()),
238 })
239 }
240}
241
242pub(crate) fn decrypt_attachments_with_failures(
246 attachments: &[Attachment],
247 ctx: &mut KeyStoreContext<KeyIds>,
248 key: SymmetricKeyId,
249) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
250 let mut successes = Vec::new();
251 let mut failures = Vec::new();
252
253 for attachment in attachments {
254 match attachment.decrypt(ctx, key) {
255 Ok(decrypted) => successes.push(decrypted),
256 Err(e) => {
257 tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
258 failures.push(AttachmentView {
259 id: attachment.id.clone(),
260 url: attachment.url.clone(),
261 size: attachment.size.clone(),
262 size_name: attachment.size_name.clone(),
263 file_name: None,
264 key: attachment.key.clone(),
265 #[cfg(feature = "wasm")]
266 decrypted_key: None,
267 });
268 }
269 }
270 }
271
272 (successes, failures)
273}
274
275impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
276 type Error = VaultParseError;
277
278 fn try_from(
279 attachment: bitwarden_api_api::models::AttachmentResponseModel,
280 ) -> Result<Self, Self::Error> {
281 Ok(Self {
282 id: attachment.id,
283 url: attachment.url,
284 size: attachment.size,
285 size_name: attachment.size_name,
286 file_name: EncString::try_from_optional(attachment.file_name)?,
287 key: EncString::try_from_optional(attachment.key)?,
288 })
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use bitwarden_core::key_management::create_test_crypto_with_user_key;
295 use bitwarden_crypto::{EncString, SymmetricCryptoKey};
296 use bitwarden_encoding::B64;
297
298 use crate::{
299 AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
300 cipher::cipher::{CipherRepromptType, CipherType},
301 };
302
303 #[test]
304 fn test_size_name_conversions() {
305 assert_eq!(super::size_name(0), "0 Bytes");
306 assert_eq!(super::size_name(19), "19 Bytes");
307 assert_eq!(super::size_name(1024), "1 KB");
308 assert_eq!(super::size_name(1570), "1.5 KB");
309 assert_eq!(super::size_name(1024 * 1024), "1 MB");
310 assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
311 assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
312 assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
313 }
314
315 #[test]
316 fn test_encrypt_attachment() {
317 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
318 let key_store = create_test_crypto_with_user_key(user_key);
319
320 let attachment = AttachmentView {
321 id: None,
322 url: None,
323 size: Some("100".into()),
324 size_name: Some("100 Bytes".into()),
325 file_name: Some("Test.txt".into()),
326 key: None,
327 #[cfg(feature = "wasm")]
328 decrypted_key: None,
329 };
330
331 let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
332
333 let attachment_file = AttachmentFileView {
334 cipher: Cipher {
335 id: None,
336 organization_id: None,
337 folder_id: None,
338 collection_ids: Vec::new(),
339 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
340 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
341 notes: None,
342 r#type: CipherType::Login,
343 login: None,
344 identity: None,
345 card: None,
346 secure_note: None,
347 ssh_key: None,
348 favorite: false,
349 reprompt: CipherRepromptType::None,
350 organization_use_totp: false,
351 edit: true,
352 permissions: None,
353 view_password: true,
354 local_data: None,
355 attachments: None,
356 fields: None,
357 password_history: None,
358 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
359 deleted_date: None,
360 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
361 archived_date: None,
362 data: None,
363 },
364 attachment,
365 contents: contents.as_slice(),
366 };
367
368 let result = key_store.encrypt(attachment_file).unwrap();
369
370 assert_eq!(result.contents.len(), 161);
371 assert_eq!(result.attachment.size, Some("161".into()));
372 assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
373 }
374
375 #[test]
376 fn test_attachment_key() {
377 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
378 let key_store = create_test_crypto_with_user_key(user_key);
379
380 let attachment = AttachmentView {
381 id: None,
382 url: None,
383 size: Some("161".into()),
384 size_name: Some("161 Bytes".into()),
385 file_name: Some("Test.txt".into()),
386 key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
387 #[cfg(feature = "wasm")]
388 decrypted_key: None,
389 };
390
391 let cipher = Cipher {
392 id: None,
393 organization_id: None,
394 folder_id: None,
395 collection_ids: Vec::new(),
396 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
397 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
398 notes: None,
399 r#type: CipherType::Login,
400 login: None,
401 identity: None,
402 card: None,
403 secure_note: None,
404 ssh_key: None,
405 favorite: false,
406 reprompt: CipherRepromptType::None,
407 organization_use_totp: false,
408 edit: true,
409 permissions: None,
410 view_password: true,
411 local_data: None,
412 attachments: None,
413 fields: None,
414 password_history: None,
415 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
416 deleted_date: None,
417 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
418 archived_date: None,
419 data: None,
420 };
421
422 let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
423 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
424
425 let dec = key_store
426 .decrypt(&AttachmentFile {
427 cipher,
428 attachment,
429 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
430 })
431 .unwrap();
432
433 assert_eq!(dec, original.as_bytes());
434 }
435
436 #[test]
437 fn test_attachment_without_key() {
438 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
439 let key_store = create_test_crypto_with_user_key(user_key);
440
441 let attachment = AttachmentView {
442 id: None,
443 url: None,
444 size: Some("161".into()),
445 size_name: Some("161 Bytes".into()),
446 file_name: Some("Test.txt".into()),
447 key: None,
448 #[cfg(feature = "wasm")]
449 decrypted_key: None,
450 };
451
452 let cipher = Cipher {
453 id: None,
454 organization_id: None,
455 folder_id: None,
456 collection_ids: Vec::new(),
457 key: None,
458 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
459 notes: None,
460 r#type: CipherType::Login,
461 login: None,
462 identity: None,
463 card: None,
464 secure_note: None,
465 ssh_key: None,
466 favorite: false,
467 reprompt: CipherRepromptType::None,
468 organization_use_totp: false,
469 edit: true,
470 permissions: None,
471 view_password: true,
472 local_data: None,
473 attachments: None,
474 fields: None,
475 password_history: None,
476 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
477 deleted_date: None,
478 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
479 archived_date: None,
480 data: None,
481 };
482
483 let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
484 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
485
486 let dec = key_store
487 .decrypt(&AttachmentFile {
488 cipher,
489 attachment,
490 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
491 })
492 .unwrap();
493
494 assert_eq!(dec, original.as_bytes());
495 }
496}