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