1use bitwarden_core::key_management::{KeyIds, SymmetricKeyId};
2use bitwarden_crypto::{
3 CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
4 OctetStreamBytes, PrimitiveEncryptable,
5};
6use serde::{Deserialize, Serialize};
7#[cfg(feature = "wasm")]
8use tsify::Tsify;
9
10use super::Cipher;
11use crate::VaultParseError;
12
13#[allow(missing_docs)]
14#[derive(Serialize, Deserialize, Debug, Clone)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
17#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
18pub struct Attachment {
19 pub id: Option<String>,
20 pub url: Option<String>,
21 pub size: Option<String>,
22 pub size_name: Option<String>,
24 pub file_name: Option<EncString>,
25 pub key: Option<EncString>,
26}
27
28#[allow(missing_docs)]
29#[derive(Serialize, Deserialize, Debug, Clone)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
32#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
33pub struct AttachmentView {
34 pub id: Option<String>,
35 pub url: Option<String>,
36 pub size: Option<String>,
37 pub size_name: Option<String>,
38 pub file_name: Option<String>,
39 pub key: Option<EncString>,
40 #[cfg(feature = "wasm")]
51 pub decrypted_key: Option<String>,
52}
53
54#[allow(missing_docs)]
55#[derive(Serialize, Deserialize, Debug)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
58pub struct AttachmentEncryptResult {
59 pub attachment: Attachment,
60 pub contents: Vec<u8>,
61}
62
63#[allow(missing_docs)]
64pub struct AttachmentFile {
65 pub cipher: Cipher,
66 pub attachment: AttachmentView,
67
68 pub contents: EncString,
73}
74
75#[allow(missing_docs)]
76pub struct AttachmentFileView<'a> {
77 pub cipher: Cipher,
78 pub attachment: AttachmentView,
79 pub contents: &'a [u8],
80}
81const ATTACHMENT_KEY: SymmetricKeyId = SymmetricKeyId::Local("attachment_key");
82
83impl IdentifyKey<SymmetricKeyId> for AttachmentFileView<'_> {
84 fn key_identifier(&self) -> SymmetricKeyId {
85 self.cipher.key_identifier()
86 }
87}
88impl IdentifyKey<SymmetricKeyId> for AttachmentFile {
89 fn key_identifier(&self) -> SymmetricKeyId {
90 self.cipher.key_identifier()
91 }
92}
93
94impl CompositeEncryptable<KeyIds, SymmetricKeyId, AttachmentEncryptResult>
95 for AttachmentFileView<'_>
96{
97 fn encrypt_composite(
98 &self,
99 ctx: &mut KeyStoreContext<KeyIds>,
100 key: SymmetricKeyId,
101 ) -> Result<AttachmentEncryptResult, CryptoError> {
102 let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
103
104 let mut attachment = self.attachment.clone();
105
106 let attachment_key = ctx.generate_symmetric_key(ATTACHMENT_KEY)?;
109 let encrypted_contents =
110 OctetStreamBytes::from(self.contents).encrypt(ctx, attachment_key)?;
111 attachment.key = Some(ctx.wrap_symmetric_key(ciphers_key, attachment_key)?);
112
113 let contents = encrypted_contents.to_buffer()?;
114
115 attachment.size = Some(contents.len().to_string());
117 attachment.size_name = Some(size_name(contents.len()));
118
119 Ok(AttachmentEncryptResult {
120 attachment: attachment.encrypt_composite(ctx, ciphers_key)?,
121 contents,
122 })
123 }
124}
125
126fn size_name(size: usize) -> String {
127 let units = ["Bytes", "KB", "MB", "GB", "TB"];
128 let size = size as f64;
129 let unit = (size.ln() / 1024_f64.ln()).floor() as usize;
130 let size = size / 1024_f64.powi(unit as i32);
131
132 let size_round = (size * 10.0_f64).round() as usize as f64 / 10.0_f64;
133 format!("{} {}", size_round, units[unit])
134}
135
136impl Decryptable<KeyIds, SymmetricKeyId, Vec<u8>> for AttachmentFile {
137 fn decrypt(
138 &self,
139 ctx: &mut KeyStoreContext<KeyIds>,
140 key: SymmetricKeyId,
141 ) -> Result<Vec<u8>, CryptoError> {
142 let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
143
144 if let Some(attachment_key) = &self.attachment.key {
146 let content_key =
147 ctx.unwrap_symmetric_key(ciphers_key, ATTACHMENT_KEY, attachment_key)?;
148 self.contents.decrypt(ctx, content_key)
149 } else {
150 self.contents.decrypt(ctx, key)
152 }
153 }
154}
155
156impl CompositeEncryptable<KeyIds, SymmetricKeyId, Attachment> for AttachmentView {
157 fn encrypt_composite(
158 &self,
159 ctx: &mut KeyStoreContext<KeyIds>,
160 key: SymmetricKeyId,
161 ) -> Result<Attachment, CryptoError> {
162 Ok(Attachment {
163 id: self.id.clone(),
164 url: self.url.clone(),
165 size: self.size.clone(),
166 size_name: self.size_name.clone(),
167 file_name: self.file_name.encrypt(ctx, key)?,
168 key: self.key.clone(),
169 })
170 }
171}
172
173impl Decryptable<KeyIds, SymmetricKeyId, AttachmentView> for Attachment {
174 fn decrypt(
175 &self,
176 ctx: &mut KeyStoreContext<KeyIds>,
177 key: SymmetricKeyId,
178 ) -> Result<AttachmentView, CryptoError> {
179 #[cfg(feature = "wasm")]
180 let decrypted_key = if let Some(attachment_key) = &self.key {
181 let content_key_id = ctx.unwrap_symmetric_key(key, ATTACHMENT_KEY, attachment_key)?;
182
183 #[allow(deprecated)]
184 let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
185
186 Some(actual_key.to_base64())
187 } else {
188 None
189 };
190
191 Ok(AttachmentView {
192 id: self.id.clone(),
193 url: self.url.clone(),
194 size: self.size.clone(),
195 size_name: self.size_name.clone(),
196 file_name: self.file_name.decrypt(ctx, key)?,
197 key: self.key.clone(),
198 #[cfg(feature = "wasm")]
199 decrypted_key,
200 })
201 }
202}
203
204impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
205 type Error = VaultParseError;
206
207 fn try_from(
208 attachment: bitwarden_api_api::models::AttachmentResponseModel,
209 ) -> Result<Self, Self::Error> {
210 Ok(Self {
211 id: attachment.id,
212 url: attachment.url,
213 size: attachment.size,
214 size_name: attachment.size_name,
215 file_name: EncString::try_from_optional(attachment.file_name)?,
216 key: EncString::try_from_optional(attachment.key)?,
217 })
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use base64::{engine::general_purpose::STANDARD, Engine};
224 use bitwarden_core::key_management::create_test_crypto_with_user_key;
225 use bitwarden_crypto::{EncString, SymmetricCryptoKey};
226
227 use crate::{
228 cipher::cipher::{CipherRepromptType, CipherType},
229 AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
230 };
231
232 #[test]
233 fn test_size_name_conversions() {
234 assert_eq!(super::size_name(0), "0 Bytes");
235 assert_eq!(super::size_name(19), "19 Bytes");
236 assert_eq!(super::size_name(1024), "1 KB");
237 assert_eq!(super::size_name(1570), "1.5 KB");
238 assert_eq!(super::size_name(1024 * 1024), "1 MB");
239 assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
240 assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
241 assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
242 }
243
244 #[test]
245 fn test_encrypt_attachment() {
246 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
247 let key_store = create_test_crypto_with_user_key(user_key);
248
249 let attachment = AttachmentView {
250 id: None,
251 url: None,
252 size: Some("100".into()),
253 size_name: Some("100 Bytes".into()),
254 file_name: Some("Test.txt".into()),
255 key: None,
256 decrypted_key: None,
257 };
258
259 let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
260
261 let attachment_file = AttachmentFileView {
262 cipher: Cipher {
263 id: None,
264 organization_id: None,
265 folder_id: None,
266 collection_ids: Vec::new(),
267 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
268 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
269 notes: None,
270 r#type: CipherType::Login,
271 login: None,
272 identity: None,
273 card: None,
274 secure_note: None,
275 ssh_key: None,
276 favorite: false,
277 reprompt: CipherRepromptType::None,
278 organization_use_totp: false,
279 edit: true,
280 permissions: None,
281 view_password: true,
282 local_data: None,
283 attachments: None,
284 fields: None,
285 password_history: None,
286 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
287 deleted_date: None,
288 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
289 },
290 attachment,
291 contents: contents.as_slice(),
292 };
293
294 let result = key_store.encrypt(attachment_file).unwrap();
295
296 assert_eq!(result.contents.len(), 161);
297 assert_eq!(result.attachment.size, Some("161".into()));
298 assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
299 }
300
301 #[test]
302 fn test_attachment_key() {
303 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
304 let key_store = create_test_crypto_with_user_key(user_key);
305
306 let attachment = AttachmentView {
307 id: None,
308 url: None,
309 size: Some("161".into()),
310 size_name: Some("161 Bytes".into()),
311 file_name: Some("Test.txt".into()),
312 key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
313 decrypted_key: None,
314 };
315
316 let cipher = Cipher {
317 id: None,
318 organization_id: None,
319 folder_id: None,
320 collection_ids: Vec::new(),
321 key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
322 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
323 notes: None,
324 r#type: CipherType::Login,
325 login: None,
326 identity: None,
327 card: None,
328 secure_note: None,
329 ssh_key: None,
330 favorite: false,
331 reprompt: CipherRepromptType::None,
332 organization_use_totp: false,
333 edit: true,
334 permissions: None,
335 view_password: true,
336 local_data: None,
337 attachments: None,
338 fields: None,
339 password_history: None,
340 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
341 deleted_date: None,
342 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
343 };
344
345 let enc_file = STANDARD.decode(b"Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
346 let original = STANDARD.decode(b"rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
347
348 let dec = key_store
349 .decrypt(&AttachmentFile {
350 cipher,
351 attachment,
352 contents: EncString::from_buffer(&enc_file).unwrap(),
353 })
354 .unwrap();
355
356 assert_eq!(dec, original);
357 }
358
359 #[test]
360 fn test_attachment_without_key() {
361 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
362 let key_store = create_test_crypto_with_user_key(user_key);
363
364 let attachment = AttachmentView {
365 id: None,
366 url: None,
367 size: Some("161".into()),
368 size_name: Some("161 Bytes".into()),
369 file_name: Some("Test.txt".into()),
370 key: None,
371 decrypted_key: None,
372 };
373
374 let cipher = Cipher {
375 id: None,
376 organization_id: None,
377 folder_id: None,
378 collection_ids: Vec::new(),
379 key: None,
380 name: "2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap(),
381 notes: None,
382 r#type: CipherType::Login,
383 login: None,
384 identity: None,
385 card: None,
386 secure_note: None,
387 ssh_key: None,
388 favorite: false,
389 reprompt: CipherRepromptType::None,
390 organization_use_totp: false,
391 edit: true,
392 permissions: None,
393 view_password: true,
394 local_data: None,
395 attachments: None,
396 fields: None,
397 password_history: None,
398 creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
399 deleted_date: None,
400 revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
401 };
402
403 let enc_file = STANDARD.decode(b"AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
404 let original = STANDARD.decode(b"rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
405
406 let dec = key_store
407 .decrypt(&AttachmentFile {
408 cipher,
409 attachment,
410 contents: EncString::from_buffer(&enc_file).unwrap(),
411 })
412 .unwrap();
413
414 assert_eq!(dec, original);
415 }
416}