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,
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<KeySlotIds>,
68 old_key: SymmetricKeySlotId,
69 new_key: SymmetricKeySlotId,
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<KeySlotIds>,
81 old_key: SymmetricKeySlotId,
82 new_key: SymmetricKeySlotId,
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<SymmetricKeySlotId> for AttachmentFileView<'_> {
120 fn key_identifier(&self) -> SymmetricKeySlotId {
121 self.cipher.key_identifier()
122 }
123}
124impl IdentifyKey<SymmetricKeySlotId> for AttachmentFile {
125 fn key_identifier(&self) -> SymmetricKeySlotId {
126 self.cipher.key_identifier()
127 }
128}
129
130impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, AttachmentEncryptResult>
131 for AttachmentFileView<'_>
132{
133 fn encrypt_composite(
134 &self,
135 ctx: &mut KeyStoreContext<KeySlotIds>,
136 key: SymmetricKeySlotId,
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<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
173 fn decrypt(
174 &self,
175 ctx: &mut KeyStoreContext<KeySlotIds>,
176 key: SymmetricKeySlotId,
177 ) -> Result<Vec<u8>, CryptoError> {
178 let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key).map_err(|e| {
179 tracing::warn!(
180 attachment_id = ?self.attachment.id,
181 cipher_id = ?self.cipher.id,
182 has_cipher_key = self.cipher.key.is_some(),
183 error = %e,
184 "Failed to decrypt cipher key for attachment"
185 );
186 e
187 })?;
188
189 if let Some(attachment_key) = &self.attachment.key {
191 let content_key = ctx
192 .unwrap_symmetric_key(ciphers_key, attachment_key)
193 .map_err(|e| {
194 tracing::warn!(
195 attachment_id = ?self.attachment.id,
196 cipher_id = ?self.cipher.id,
197 error = %e,
198 "Failed to unwrap attachment key (v2/v3)"
199 );
200 e
201 })?;
202 self.contents.decrypt(ctx, content_key).map_err(|e| {
203 tracing::warn!(
204 attachment_id = ?self.attachment.id,
205 cipher_id = ?self.cipher.id,
206 error = %e,
207 "Failed to decrypt attachment contents with attachment key (v2/v3)"
208 );
209 e
210 })
211 } else {
212 self.contents.decrypt(ctx, key).map_err(|e| {
214 tracing::warn!(
215 attachment_id = ?self.attachment.id,
216 cipher_id = ?self.cipher.id,
217 error = %e,
218 "Failed to decrypt attachment contents with user/org key (legacy v1)"
219 );
220 e
221 })
222 }
223 }
224}
225
226impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentView {
227 fn encrypt_composite(
228 &self,
229 ctx: &mut KeyStoreContext<KeySlotIds>,
230 key: SymmetricKeySlotId,
231 ) -> Result<Attachment, CryptoError> {
232 Ok(Attachment {
233 id: self.id.clone(),
234 url: self.url.clone(),
235 size: self.size.clone(),
236 size_name: self.size_name.clone(),
237 file_name: self.file_name.encrypt(ctx, key)?,
238 key: self.key.clone(),
239 })
240 }
241}
242
243impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
244 fn decrypt(
245 &self,
246 ctx: &mut KeyStoreContext<KeySlotIds>,
247 key: SymmetricKeySlotId,
248 ) -> Result<AttachmentView, CryptoError> {
249 let file_name = self.file_name.decrypt(ctx, key)?;
251
252 #[cfg(feature = "wasm")]
253 let decrypted_key = if let Some(attachment_key) = &self.key {
254 let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
255
256 #[allow(deprecated)]
257 let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
258
259 Some(actual_key.to_base64())
260 } else {
261 None
262 };
263
264 Ok(AttachmentView {
265 id: self.id.clone(),
266 url: self.url.clone(),
267 size: self.size.clone(),
268 size_name: self.size_name.clone(),
269 file_name,
270 key: self.key.clone(),
271 #[cfg(feature = "wasm")]
272 decrypted_key: decrypted_key.map(|k| k.to_string()),
273 })
274 }
275}
276
277pub(crate) fn decrypt_attachments_with_failures(
281 attachments: &[Attachment],
282 ctx: &mut KeyStoreContext<KeySlotIds>,
283 key: SymmetricKeySlotId,
284) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
285 let mut successes = Vec::new();
286 let mut failures = Vec::new();
287
288 for attachment in attachments {
289 match attachment.decrypt(ctx, key) {
290 Ok(decrypted) => successes.push(decrypted),
291 Err(e) => {
292 tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
293 failures.push(AttachmentView {
294 id: attachment.id.clone(),
295 url: attachment.url.clone(),
296 size: attachment.size.clone(),
297 size_name: attachment.size_name.clone(),
298 file_name: None,
299 key: attachment.key.clone(),
300 #[cfg(feature = "wasm")]
301 decrypted_key: None,
302 });
303 }
304 }
305 }
306
307 (successes, failures)
308}
309
310impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
311 type Error = VaultParseError;
312
313 fn try_from(
314 attachment: bitwarden_api_api::models::AttachmentResponseModel,
315 ) -> Result<Self, Self::Error> {
316 Ok(Self {
317 id: attachment.id,
318 url: attachment.url,
319 size: attachment.size,
320 size_name: attachment.size_name,
321 file_name: EncString::try_from_optional(attachment.file_name)?,
322 key: EncString::try_from_optional(attachment.key)?,
323 })
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use bitwarden_core::key_management::create_test_crypto_with_user_key;
330 use bitwarden_crypto::{EncString, SymmetricCryptoKey};
331 use bitwarden_encoding::B64;
332
333 use crate::{
334 AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
335 cipher::cipher::{CipherRepromptType, CipherType},
336 };
337
338 #[test]
339 fn test_size_name_conversions() {
340 assert_eq!(super::size_name(0), "0 Bytes");
341 assert_eq!(super::size_name(19), "19 Bytes");
342 assert_eq!(super::size_name(1024), "1 KB");
343 assert_eq!(super::size_name(1570), "1.5 KB");
344 assert_eq!(super::size_name(1024 * 1024), "1 MB");
345 assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
346 assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
347 assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
348 }
349
350 #[test]
351 fn test_encrypt_attachment() {
352 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
353 let key_store = create_test_crypto_with_user_key(user_key);
354
355 let attachment = AttachmentView {
356 id: None,
357 url: None,
358 size: Some("100".into()),
359 size_name: Some("100 Bytes".into()),
360 file_name: Some("Test.txt".into()),
361 key: None,
362 #[cfg(feature = "wasm")]
363 decrypted_key: None,
364 };
365
366 let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
367
368 let attachment_file = AttachmentFileView {
369 cipher: Cipher {
370 id: None,
371 organization_id: None,
372 folder_id: None,
373 collection_ids: Vec::new(),
374 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
375 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
376 notes: None,
377 r#type: CipherType::Login,
378 login: None,
379 identity: None,
380 card: None,
381 secure_note: None,
382 ssh_key: None,
383 bank_account: None,
384 favorite: false,
385 reprompt: CipherRepromptType::None,
386 organization_use_totp: false,
387 edit: true,
388 permissions: None,
389 view_password: true,
390 local_data: None,
391 attachments: None,
392 fields: None,
393 password_history: None,
394 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
395 deleted_date: None,
396 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
397 archived_date: None,
398 data: None,
399 },
400 attachment,
401 contents: contents.as_slice(),
402 };
403
404 let result = key_store.encrypt(attachment_file).unwrap();
405
406 assert_eq!(result.contents.len(), 161);
407 assert_eq!(result.attachment.size, Some("161".into()));
408 assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
409 }
410
411 #[test]
412 fn test_attachment_key() {
413 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
414 let key_store = create_test_crypto_with_user_key(user_key);
415
416 let attachment = AttachmentView {
417 id: None,
418 url: None,
419 size: Some("161".into()),
420 size_name: Some("161 Bytes".into()),
421 file_name: Some("Test.txt".into()),
422 key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
423 #[cfg(feature = "wasm")]
424 decrypted_key: None,
425 };
426
427 let cipher = Cipher {
428 id: None,
429 organization_id: None,
430 folder_id: None,
431 collection_ids: Vec::new(),
432 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
433 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
434 notes: None,
435 r#type: CipherType::Login,
436 login: None,
437 identity: None,
438 card: None,
439 secure_note: None,
440 ssh_key: None,
441 bank_account: None,
442 favorite: false,
443 reprompt: CipherRepromptType::None,
444 organization_use_totp: false,
445 edit: true,
446 permissions: None,
447 view_password: true,
448 local_data: None,
449 attachments: None,
450 fields: None,
451 password_history: None,
452 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
453 deleted_date: None,
454 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
455 archived_date: None,
456 data: None,
457 };
458
459 let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
460 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
461
462 let dec = key_store
463 .decrypt(&AttachmentFile {
464 cipher,
465 attachment,
466 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
467 })
468 .unwrap();
469
470 assert_eq!(dec, original.as_bytes());
471 }
472
473 #[test]
474 fn test_attachment_without_key() {
475 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
476 let key_store = create_test_crypto_with_user_key(user_key);
477
478 let attachment = AttachmentView {
479 id: None,
480 url: None,
481 size: Some("161".into()),
482 size_name: Some("161 Bytes".into()),
483 file_name: Some("Test.txt".into()),
484 key: None,
485 #[cfg(feature = "wasm")]
486 decrypted_key: None,
487 };
488
489 let cipher = Cipher {
490 id: None,
491 organization_id: None,
492 folder_id: None,
493 collection_ids: Vec::new(),
494 key: None,
495 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
496 notes: None,
497 r#type: CipherType::Login,
498 login: None,
499 identity: None,
500 card: None,
501 secure_note: None,
502 ssh_key: None,
503 bank_account: None,
504 favorite: false,
505 reprompt: CipherRepromptType::None,
506 organization_use_totp: false,
507 edit: true,
508 permissions: None,
509 view_password: true,
510 local_data: None,
511 attachments: None,
512 fields: None,
513 password_history: None,
514 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
515 deleted_date: None,
516 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
517 archived_date: None,
518 data: None,
519 };
520
521 let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
522 let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
523
524 let dec = key_store
525 .decrypt(&AttachmentFile {
526 cipher,
527 attachment,
528 contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
529 })
530 .unwrap();
531
532 assert_eq!(dec, original.as_bytes());
533 }
534}