1use bitwarden_api_api::models::CipherAttachmentModel;
2use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
3use bitwarden_crypto::{
4 CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
5 OctetStreamBytes, PrimitiveEncryptable, SymmetricCryptoKey,
6};
7use serde::{Deserialize, Serialize};
8#[cfg(feature = "wasm")]
9use tsify::Tsify;
10
11use super::Cipher;
12use crate::VaultParseError;
13
14pub(crate) struct AttachmentMaterial {
16 pub(crate) key: SymmetricCryptoKey,
18 pub(crate) wrapped_key: EncString,
20 pub(crate) encrypted_file_name: EncString,
22}
23
24#[allow(missing_docs)]
25#[derive(Serialize, Deserialize, Debug, Clone)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
28#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
29pub struct Attachment {
30 pub id: Option<String>,
31 pub url: Option<String>,
32 pub size: Option<String>,
33 pub size_name: Option<String>,
35 pub file_name: Option<EncString>,
36 pub key: Option<EncString>,
37}
38
39impl From<Attachment> for CipherAttachmentModel {
40 fn from(attachment: Attachment) -> Self {
41 Self {
42 file_name: attachment.file_name.map(|f| f.to_string()),
43 key: attachment.key.map(|k| k.to_string()),
44 }
45 }
46}
47
48#[derive(Debug, PartialEq, Eq)]
50pub(crate) enum AttachmentEncryptionVersion {
51 LegacyNoKeyV1,
53 AttachmentKeyV2,
55}
56
57impl Attachment {
58 pub(crate) fn encryption_version(&self) -> AttachmentEncryptionVersion {
60 match self.key {
61 Some(_) => AttachmentEncryptionVersion::AttachmentKeyV2,
62 None => AttachmentEncryptionVersion::LegacyNoKeyV1,
63 }
64 }
65}
66
67#[allow(missing_docs)]
68#[derive(Serialize, Deserialize, Debug, Clone)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
71#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
72pub struct AttachmentView {
73 pub id: Option<String>,
74 pub url: Option<String>,
75 pub size: Option<String>,
76 pub size_name: Option<String>,
77 pub file_name: Option<String>,
78 pub key: Option<EncString>,
79 #[cfg(feature = "wasm")]
90 pub decrypted_key: Option<String>,
91}
92
93impl AttachmentView {
94 pub(crate) fn reencrypt_key(
95 &mut self,
96 ctx: &mut KeyStoreContext<KeySlotIds>,
97 old_key: SymmetricKeySlotId,
98 new_key: SymmetricKeySlotId,
99 ) -> Result<(), CryptoError> {
100 if let Some(attachment_key) = &mut self.key {
101 let tmp_attachment_key_id = ctx.unwrap_symmetric_key(old_key, attachment_key)?;
102 *attachment_key = ctx.wrap_symmetric_key(new_key, tmp_attachment_key_id)?;
103 }
104 Ok(())
105 }
106
107 pub(crate) fn reencrypt_keys(
108 attachment_views: &mut Vec<AttachmentView>,
109 ctx: &mut KeyStoreContext<KeySlotIds>,
110 old_key: SymmetricKeySlotId,
111 new_key: SymmetricKeySlotId,
112 ) -> Result<(), CryptoError> {
113 for attachment in attachment_views {
114 attachment.reencrypt_key(ctx, old_key, new_key)?;
115 }
116 Ok(())
117 }
118}
119
120#[allow(missing_docs)]
121#[derive(Serialize, Deserialize, Debug)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
124pub struct AttachmentEncryptResult {
125 pub attachment: Attachment,
126 pub contents: Vec<u8>,
127}
128
129#[allow(missing_docs)]
130pub struct AttachmentFile {
131 pub cipher: Cipher,
132 pub attachment: AttachmentView,
133
134 pub contents: EncString,
139}
140
141#[allow(missing_docs)]
142pub struct AttachmentFileView<'a> {
143 pub cipher: Cipher,
144 pub attachment: AttachmentView,
145 pub contents: &'a [u8],
146}
147
148impl IdentifyKey<SymmetricKeySlotId> for AttachmentFileView<'_> {
149 fn key_identifier(&self) -> SymmetricKeySlotId {
150 self.cipher.key_identifier()
151 }
152}
153impl IdentifyKey<SymmetricKeySlotId> for AttachmentFile {
154 fn key_identifier(&self) -> SymmetricKeySlotId {
155 self.cipher.key_identifier()
156 }
157}
158
159impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, AttachmentEncryptResult>
160 for AttachmentFileView<'_>
161{
162 fn encrypt_composite(
163 &self,
164 ctx: &mut KeyStoreContext<KeySlotIds>,
165 key: SymmetricKeySlotId,
166 ) -> Result<AttachmentEncryptResult, CryptoError> {
167 let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
168
169 let mut attachment = self.attachment.clone();
170
171 let attachment_key = ctx.generate_symmetric_key();
174 let encrypted_contents =
175 OctetStreamBytes::from(self.contents).encrypt(ctx, attachment_key)?;
176 attachment.key = Some(ctx.wrap_symmetric_key(ciphers_key, attachment_key)?);
177
178 let contents = encrypted_contents.to_buffer()?;
179
180 attachment.size = Some(contents.len().to_string());
182 attachment.size_name = Some(size_name(contents.len()));
183
184 Ok(AttachmentEncryptResult {
185 attachment: attachment.encrypt_composite(ctx, ciphers_key)?,
186 contents,
187 })
188 }
189}
190
191fn size_name(size: usize) -> String {
192 let units = ["Bytes", "KB", "MB", "GB", "TB"];
193 let size = size as f64;
194 let unit = (size.ln() / 1024_f64.ln()).floor() as usize;
195 let size = size / 1024_f64.powi(unit as i32);
196
197 let size_round = (size * 10.0_f64).round() as usize as f64 / 10.0_f64;
198 format!("{} {}", size_round, units[unit])
199}
200
201impl Decryptable<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
202 fn decrypt(
203 &self,
204 ctx: &mut KeyStoreContext<KeySlotIds>,
205 key: SymmetricKeySlotId,
206 ) -> Result<Vec<u8>, CryptoError> {
207 let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key).map_err(|e| {
208 tracing::warn!(
209 attachment_id = ?self.attachment.id,
210 cipher_id = ?self.cipher.id,
211 has_cipher_key = self.cipher.key.is_some(),
212 error = %e,
213 "Failed to decrypt cipher key for attachment"
214 );
215 e
216 })?;
217
218 if let Some(attachment_key) = &self.attachment.key {
220 let content_key = ctx
221 .unwrap_symmetric_key(ciphers_key, attachment_key)
222 .map_err(|e| {
223 tracing::warn!(
224 attachment_id = ?self.attachment.id,
225 cipher_id = ?self.cipher.id,
226 error = %e,
227 "Failed to unwrap attachment key (v2/v3)"
228 );
229 e
230 })?;
231 self.contents.decrypt(ctx, content_key).map_err(|e| {
232 tracing::warn!(
233 attachment_id = ?self.attachment.id,
234 cipher_id = ?self.cipher.id,
235 error = %e,
236 "Failed to decrypt attachment contents with attachment key (v2/v3)"
237 );
238 e
239 })
240 } else {
241 self.contents.decrypt(ctx, key).map_err(|e| {
243 tracing::warn!(
244 attachment_id = ?self.attachment.id,
245 cipher_id = ?self.cipher.id,
246 error = %e,
247 "Failed to decrypt attachment contents with user/org key (legacy v1)"
248 );
249 e
250 })
251 }
252 }
253}
254
255impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentView {
260 fn encrypt_composite(
261 &self,
262 ctx: &mut KeyStoreContext<KeySlotIds>,
263 key: SymmetricKeySlotId,
264 ) -> Result<Attachment, CryptoError> {
265 Ok(Attachment {
266 id: self.id.clone(),
267 url: self.url.clone(),
268 size: self.size.clone(),
269 size_name: self.size_name.clone(),
270 file_name: self.file_name.encrypt(ctx, key)?,
271 key: self.key.clone(),
274 })
275 }
276}
277
278impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
279 fn decrypt(
280 &self,
281 ctx: &mut KeyStoreContext<KeySlotIds>,
282 key: SymmetricKeySlotId,
283 ) -> Result<AttachmentView, CryptoError> {
284 let file_name = self.file_name.decrypt(ctx, key)?;
286
287 #[cfg(feature = "wasm")]
288 let decrypted_key = if let Some(attachment_key) = &self.key {
289 let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
290
291 #[allow(deprecated)]
292 let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
293
294 Some(actual_key.to_base64())
295 } else {
296 None
297 };
298
299 Ok(AttachmentView {
300 id: self.id.clone(),
301 url: self.url.clone(),
302 size: self.size.clone(),
303 size_name: self.size_name.clone(),
304 file_name,
305 key: self.key.clone(),
311 #[cfg(feature = "wasm")]
312 decrypted_key: decrypted_key.map(|k| k.to_string()),
313 })
314 }
315}
316
317pub(crate) fn decrypt_attachments_with_failures(
321 attachments: &[Attachment],
322 ctx: &mut KeyStoreContext<KeySlotIds>,
323 key: SymmetricKeySlotId,
324) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
325 let mut successes = Vec::new();
326 let mut failures = Vec::new();
327
328 for attachment in attachments {
329 match attachment.decrypt(ctx, key) {
330 Ok(decrypted) => successes.push(decrypted),
331 Err(e) => {
332 tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
333 failures.push(AttachmentView {
334 id: attachment.id.clone(),
335 url: attachment.url.clone(),
336 size: attachment.size.clone(),
337 size_name: attachment.size_name.clone(),
338 file_name: None,
339 key: attachment.key.clone(),
340 #[cfg(feature = "wasm")]
341 decrypted_key: None,
342 });
343 }
344 }
345 }
346
347 (successes, failures)
348}
349
350impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
351 type Error = VaultParseError;
352
353 fn try_from(
354 attachment: bitwarden_api_api::models::AttachmentResponseModel,
355 ) -> Result<Self, Self::Error> {
356 Ok(Self {
357 id: attachment.id,
358 url: attachment.url,
359 size: attachment.size,
360 size_name: attachment.size_name,
361 file_name: EncString::try_from_optional(attachment.file_name)?,
362 key: EncString::try_from_optional(attachment.key)?,
363 })
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use bitwarden_core::key_management::create_test_crypto_with_user_key;
370 use bitwarden_crypto::{EncString, SymmetricCryptoKey};
371 use bitwarden_encoding::B64;
372
373 use crate::{
374 AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
375 cipher::cipher::{CipherRepromptType, CipherType},
376 };
377
378 #[test]
379 fn test_size_name_conversions() {
380 assert_eq!(super::size_name(0), "0 Bytes");
381 assert_eq!(super::size_name(19), "19 Bytes");
382 assert_eq!(super::size_name(1024), "1 KB");
383 assert_eq!(super::size_name(1570), "1.5 KB");
384 assert_eq!(super::size_name(1024 * 1024), "1 MB");
385 assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
386 assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
387 assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
388 }
389
390 #[test]
391 fn test_encrypt_attachment() {
392 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
393 let key_store = create_test_crypto_with_user_key(user_key);
394
395 let attachment = AttachmentView {
396 id: None,
397 url: None,
398 size: Some("100".into()),
399 size_name: Some("100 Bytes".into()),
400 file_name: Some("Test.txt".into()),
401 key: None,
402 #[cfg(feature = "wasm")]
403 decrypted_key: None,
404 };
405
406 let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
407
408 let attachment_file = AttachmentFileView {
409 cipher: Cipher {
410 id: None,
411 organization_id: None,
412 folder_id: None,
413 collection_ids: Vec::new(),
414 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
415 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
416 notes: None,
417 r#type: CipherType::Login,
418 login: None,
419 identity: None,
420 card: None,
421 secure_note: None,
422 ssh_key: None,
423 bank_account: None,
424 drivers_license: None,
425 passport: None,
426 favorite: false,
427 reprompt: CipherRepromptType::None,
428 organization_use_totp: false,
429 edit: true,
430 permissions: None,
431 view_password: true,
432 local_data: None,
433 attachments: None,
434 fields: None,
435 password_history: None,
436 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
437 deleted_date: None,
438 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
439 archived_date: None,
440 data: None,
441 },
442 attachment,
443 contents: contents.as_slice(),
444 };
445
446 let result = key_store.encrypt(attachment_file).unwrap();
447
448 assert_eq!(result.contents.len(), 161);
449 assert_eq!(result.attachment.size, Some("161".into()));
450 assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
451 }
452
453 #[test]
454 fn test_attachment_key() {
455 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
456 let key_store = create_test_crypto_with_user_key(user_key);
457
458 let attachment = AttachmentView {
459 id: None,
460 url: None,
461 size: Some("161".into()),
462 size_name: Some("161 Bytes".into()),
463 file_name: Some("Test.txt".into()),
464 key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
465 #[cfg(feature = "wasm")]
466 decrypted_key: None,
467 };
468
469 let cipher = Cipher {
470 id: None,
471 organization_id: None,
472 folder_id: None,
473 collection_ids: Vec::new(),
474 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
475 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
476 notes: None,
477 r#type: CipherType::Login,
478 login: None,
479 identity: None,
480 card: None,
481 secure_note: None,
482 ssh_key: None,
483 bank_account: None,
484 drivers_license: None,
485 passport: None,
486 favorite: false,
487 reprompt: CipherRepromptType::None,
488 organization_use_totp: false,
489 edit: true,
490 permissions: None,
491 view_password: true,
492 local_data: None,
493 attachments: None,
494 fields: None,
495 password_history: None,
496 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
497 deleted_date: None,
498 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
499 archived_date: None,
500 data: None,
501 };
502
503 let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
504 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
505
506 let dec = key_store
507 .decrypt(&AttachmentFile {
508 cipher,
509 attachment,
510 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
511 })
512 .unwrap();
513
514 assert_eq!(dec, original.as_bytes());
515 }
516
517 #[test]
518 fn test_attachment_without_key() {
519 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
520 let key_store = create_test_crypto_with_user_key(user_key);
521
522 let attachment = AttachmentView {
523 id: None,
524 url: None,
525 size: Some("161".into()),
526 size_name: Some("161 Bytes".into()),
527 file_name: Some("Test.txt".into()),
528 key: None,
529 #[cfg(feature = "wasm")]
530 decrypted_key: None,
531 };
532
533 let cipher = Cipher {
534 id: None,
535 organization_id: None,
536 folder_id: None,
537 collection_ids: Vec::new(),
538 key: None,
539 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
540 notes: None,
541 r#type: CipherType::Login,
542 login: None,
543 identity: None,
544 card: None,
545 secure_note: None,
546 ssh_key: None,
547 bank_account: None,
548 drivers_license: None,
549 passport: None,
550 favorite: false,
551 reprompt: CipherRepromptType::None,
552 organization_use_totp: false,
553 edit: true,
554 permissions: None,
555 view_password: true,
556 local_data: None,
557 attachments: None,
558 fields: None,
559 password_history: None,
560 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
561 deleted_date: None,
562 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
563 archived_date: None,
564 data: None,
565 };
566
567 let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
568 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
569
570 let dec = key_store
571 .decrypt(&AttachmentFile {
572 cipher,
573 attachment,
574 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
575 })
576 .unwrap();
577
578 assert_eq!(dec, original.as_bytes());
579 }
580}