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 {
256 fn encrypt_composite(
257 &self,
258 ctx: &mut KeyStoreContext<KeySlotIds>,
259 key: SymmetricKeySlotId,
260 ) -> Result<Attachment, CryptoError> {
261 Ok(Attachment {
262 id: self.id.clone(),
263 url: self.url.clone(),
264 size: self.size.clone(),
265 size_name: self.size_name.clone(),
266 file_name: self.file_name.encrypt(ctx, key)?,
267 key: self.key.clone(),
268 })
269 }
270}
271
272impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
273 fn decrypt(
274 &self,
275 ctx: &mut KeyStoreContext<KeySlotIds>,
276 key: SymmetricKeySlotId,
277 ) -> Result<AttachmentView, CryptoError> {
278 let file_name = self.file_name.decrypt(ctx, key)?;
280
281 #[cfg(feature = "wasm")]
282 let decrypted_key = if let Some(attachment_key) = &self.key {
283 let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
284
285 #[allow(deprecated)]
286 let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
287
288 Some(actual_key.to_base64())
289 } else {
290 None
291 };
292
293 Ok(AttachmentView {
294 id: self.id.clone(),
295 url: self.url.clone(),
296 size: self.size.clone(),
297 size_name: self.size_name.clone(),
298 file_name,
299 key: self.key.clone(),
300 #[cfg(feature = "wasm")]
301 decrypted_key: decrypted_key.map(|k| k.to_string()),
302 })
303 }
304}
305
306pub(crate) fn decrypt_attachments_with_failures(
310 attachments: &[Attachment],
311 ctx: &mut KeyStoreContext<KeySlotIds>,
312 key: SymmetricKeySlotId,
313) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
314 let mut successes = Vec::new();
315 let mut failures = Vec::new();
316
317 for attachment in attachments {
318 match attachment.decrypt(ctx, key) {
319 Ok(decrypted) => successes.push(decrypted),
320 Err(e) => {
321 tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
322 failures.push(AttachmentView {
323 id: attachment.id.clone(),
324 url: attachment.url.clone(),
325 size: attachment.size.clone(),
326 size_name: attachment.size_name.clone(),
327 file_name: None,
328 key: attachment.key.clone(),
329 #[cfg(feature = "wasm")]
330 decrypted_key: None,
331 });
332 }
333 }
334 }
335
336 (successes, failures)
337}
338
339impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
340 type Error = VaultParseError;
341
342 fn try_from(
343 attachment: bitwarden_api_api::models::AttachmentResponseModel,
344 ) -> Result<Self, Self::Error> {
345 Ok(Self {
346 id: attachment.id,
347 url: attachment.url,
348 size: attachment.size,
349 size_name: attachment.size_name,
350 file_name: EncString::try_from_optional(attachment.file_name)?,
351 key: EncString::try_from_optional(attachment.key)?,
352 })
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use bitwarden_core::key_management::create_test_crypto_with_user_key;
359 use bitwarden_crypto::{EncString, SymmetricCryptoKey};
360 use bitwarden_encoding::B64;
361
362 use crate::{
363 AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
364 cipher::cipher::{CipherRepromptType, CipherType},
365 };
366
367 #[test]
368 fn test_size_name_conversions() {
369 assert_eq!(super::size_name(0), "0 Bytes");
370 assert_eq!(super::size_name(19), "19 Bytes");
371 assert_eq!(super::size_name(1024), "1 KB");
372 assert_eq!(super::size_name(1570), "1.5 KB");
373 assert_eq!(super::size_name(1024 * 1024), "1 MB");
374 assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
375 assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
376 assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
377 }
378
379 #[test]
380 fn test_encrypt_attachment() {
381 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
382 let key_store = create_test_crypto_with_user_key(user_key);
383
384 let attachment = AttachmentView {
385 id: None,
386 url: None,
387 size: Some("100".into()),
388 size_name: Some("100 Bytes".into()),
389 file_name: Some("Test.txt".into()),
390 key: None,
391 #[cfg(feature = "wasm")]
392 decrypted_key: None,
393 };
394
395 let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
396
397 let attachment_file = AttachmentFileView {
398 cipher: Cipher {
399 id: None,
400 organization_id: None,
401 folder_id: None,
402 collection_ids: Vec::new(),
403 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
404 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
405 notes: None,
406 r#type: CipherType::Login,
407 login: None,
408 identity: None,
409 card: None,
410 secure_note: None,
411 ssh_key: None,
412 bank_account: None,
413 drivers_license: None,
414 passport: None,
415 favorite: false,
416 reprompt: CipherRepromptType::None,
417 organization_use_totp: false,
418 edit: true,
419 permissions: None,
420 view_password: true,
421 local_data: None,
422 attachments: None,
423 fields: None,
424 password_history: None,
425 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
426 deleted_date: None,
427 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
428 archived_date: None,
429 data: None,
430 },
431 attachment,
432 contents: contents.as_slice(),
433 };
434
435 let result = key_store.encrypt(attachment_file).unwrap();
436
437 assert_eq!(result.contents.len(), 161);
438 assert_eq!(result.attachment.size, Some("161".into()));
439 assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
440 }
441
442 #[test]
443 fn test_attachment_key() {
444 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
445 let key_store = create_test_crypto_with_user_key(user_key);
446
447 let attachment = AttachmentView {
448 id: None,
449 url: None,
450 size: Some("161".into()),
451 size_name: Some("161 Bytes".into()),
452 file_name: Some("Test.txt".into()),
453 key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
454 #[cfg(feature = "wasm")]
455 decrypted_key: None,
456 };
457
458 let cipher = Cipher {
459 id: None,
460 organization_id: None,
461 folder_id: None,
462 collection_ids: Vec::new(),
463 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
464 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
465 notes: None,
466 r#type: CipherType::Login,
467 login: None,
468 identity: None,
469 card: None,
470 secure_note: None,
471 ssh_key: None,
472 bank_account: None,
473 drivers_license: None,
474 passport: None,
475 favorite: false,
476 reprompt: CipherRepromptType::None,
477 organization_use_totp: false,
478 edit: true,
479 permissions: None,
480 view_password: true,
481 local_data: None,
482 attachments: None,
483 fields: None,
484 password_history: None,
485 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
486 deleted_date: None,
487 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
488 archived_date: None,
489 data: None,
490 };
491
492 let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
493 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
494
495 let dec = key_store
496 .decrypt(&AttachmentFile {
497 cipher,
498 attachment,
499 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
500 })
501 .unwrap();
502
503 assert_eq!(dec, original.as_bytes());
504 }
505
506 #[test]
507 fn test_attachment_without_key() {
508 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
509 let key_store = create_test_crypto_with_user_key(user_key);
510
511 let attachment = AttachmentView {
512 id: None,
513 url: None,
514 size: Some("161".into()),
515 size_name: Some("161 Bytes".into()),
516 file_name: Some("Test.txt".into()),
517 key: None,
518 #[cfg(feature = "wasm")]
519 decrypted_key: None,
520 };
521
522 let cipher = Cipher {
523 id: None,
524 organization_id: None,
525 folder_id: None,
526 collection_ids: Vec::new(),
527 key: None,
528 name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
529 notes: None,
530 r#type: CipherType::Login,
531 login: None,
532 identity: None,
533 card: None,
534 secure_note: None,
535 ssh_key: None,
536 bank_account: None,
537 drivers_license: None,
538 passport: None,
539 favorite: false,
540 reprompt: CipherRepromptType::None,
541 organization_use_totp: false,
542 edit: true,
543 permissions: None,
544 view_password: true,
545 local_data: None,
546 attachments: None,
547 fields: None,
548 password_history: None,
549 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
550 deleted_date: None,
551 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
552 archived_date: None,
553 data: None,
554 };
555
556 let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
557 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
558
559 let dec = key_store
560 .decrypt(&AttachmentFile {
561 cipher,
562 attachment,
563 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
564 })
565 .unwrap();
566
567 assert_eq!(dec, original.as_bytes());
568 }
569}