1use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel};
2use bitwarden_core::{ApiError, key_management::KeySlotIds};
3use bitwarden_crypto::{CryptoError, KeyStore};
4use bitwarden_error::bitwarden_error;
5use bitwarden_state::repository::{Repository, RepositoryError};
6use futures::future::OptionFuture;
7use thiserror::Error;
8#[cfg(feature = "wasm")]
9use wasm_bindgen::prelude::wasm_bindgen;
10
11use crate::{
12 Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, VaultParseError,
13 cipher::cipher::{PartialCipher, StrictDecrypt},
14};
15
16#[allow(missing_docs)]
17#[bitwarden_error(flat)]
18#[derive(Debug, Error)]
19pub enum RestoreCipherError {
20 #[error(transparent)]
21 Api(#[from] ApiError),
22 #[error(transparent)]
23 VaultParse(#[from] VaultParseError),
24 #[error(transparent)]
25 Repository(#[from] RepositoryError),
26 #[error(transparent)]
27 Crypto(#[from] CryptoError),
28}
29
30pub async fn restore<R: Repository<Cipher> + ?Sized>(
32 cipher_id: CipherId,
33 api_client: &ApiClient,
34 repository: &R,
35 key_store: &KeyStore<KeySlotIds>,
36 use_strict_decryption: bool,
37) -> Result<CipherView, RestoreCipherError> {
38 let api = api_client.ciphers_api();
39
40 let existing_cipher = repository.get(cipher_id).await?;
41 let cipher: Cipher = api
42 .put_restore(cipher_id.into())
43 .await?
44 .merge_with_cipher(existing_cipher)?;
45 repository.set(cipher_id, cipher.clone()).await?;
46
47 Ok(if use_strict_decryption {
48 key_store.decrypt(&StrictDecrypt(cipher))?
49 } else {
50 key_store.decrypt(&cipher)?
51 })
52}
53
54pub async fn restore_many<R: Repository<Cipher> + ?Sized>(
56 cipher_ids: Vec<CipherId>,
57 api_client: &ApiClient,
58 repository: &R,
59 key_store: &KeyStore<KeySlotIds>,
60 use_strict_decryption: bool,
61) -> Result<DecryptCipherListResult, RestoreCipherError> {
62 let api = api_client.ciphers_api();
63
64 let response_models: Vec<_> = api
65 .put_restore_many(Some(CipherBulkRestoreRequestModel {
66 ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(),
67 organization_id: None,
68 }))
69 .await?
70 .data
71 .into_iter()
72 .flatten()
73 .collect();
74
75 let mut ciphers = Vec::with_capacity(response_models.len());
76 for model in response_models {
77 let existing = OptionFuture::from(model.id.map(|id| repository.get(CipherId::new(id))))
78 .await
79 .transpose()?
80 .flatten();
81 ciphers.push(model.merge_with_cipher(existing)?);
82 }
83
84 for cipher in &ciphers {
85 if let Some(id) = cipher.id {
86 repository.set(id, cipher.clone()).await?;
87 }
88 }
89
90 Ok(if use_strict_decryption {
91 let wrapped: Vec<StrictDecrypt<Cipher>> = ciphers.into_iter().map(StrictDecrypt).collect();
92 let (successes, failures) = key_store.decrypt_list_with_failures(&wrapped);
93 DecryptCipherListResult {
94 successes,
95 failures: failures.into_iter().map(|f| f.0.clone()).collect(),
96 }
97 } else {
98 let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
99 DecryptCipherListResult {
100 successes,
101 failures: failures.into_iter().cloned().collect(),
102 }
103 })
104}
105
106#[allow(deprecated)]
107#[cfg_attr(feature = "wasm", wasm_bindgen)]
108impl CiphersClient {
109 pub async fn restore(&self, cipher_id: CipherId) -> Result<CipherView, RestoreCipherError> {
111 let api_client = &self.client.internal.get_api_configurations().api_client;
112 let key_store = self.client.internal.get_key_store();
113
114 restore(
115 cipher_id,
116 api_client,
117 &*self.get_repository()?,
118 key_store,
119 self.is_strict_decrypt().await,
120 )
121 .await
122 }
123
124 pub async fn restore_many(
126 &self,
127 cipher_ids: Vec<CipherId>,
128 ) -> Result<DecryptCipherListResult, RestoreCipherError> {
129 let api_client = &self.client.internal.get_api_configurations().api_client;
130 let key_store = self.client.internal.get_key_store();
131 let repository = &*self.get_repository()?;
132
133 restore_many(
134 cipher_ids,
135 api_client,
136 repository,
137 key_store,
138 self.is_strict_decrypt().await,
139 )
140 .await
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use bitwarden_api_api::{
147 apis::ApiClient,
148 models::{
149 CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel,
150 },
151 };
152 use bitwarden_collections::collection::CollectionId;
153 use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
154 use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
155 use bitwarden_state::repository::Repository;
156 use bitwarden_test::MemoryRepository;
157 use chrono::Utc;
158
159 use super::*;
160 use crate::{Cipher, CipherId, Login};
161
162 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
163 const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098";
164
165 fn setup_key_store() -> KeyStore<KeySlotIds> {
166 let store: KeyStore<KeySlotIds> = KeyStore::default();
167 #[allow(deprecated)]
168 let _ = store.context_mut().set_symmetric_key(
169 SymmetricKeySlotId::User,
170 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
171 );
172 store
173 }
174
175 fn generate_test_cipher() -> Cipher {
176 Cipher {
177 partial_data: None,
178 id: TEST_CIPHER_ID.parse().ok(),
179 name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap()),
180 r#type: crate::CipherType::Login,
181 notes: Default::default(),
182 organization_id: Default::default(),
183 folder_id: Default::default(),
184 favorite: Default::default(),
185 reprompt: Default::default(),
186 fields: Default::default(),
187 collection_ids: Default::default(),
188 key: Default::default(),
189 login: Some(Login{
190 username: None,
191 password: None,
192 password_revision_date: None,
193 uris: None, totp: None,
194 autofill_on_page_load: None,
195 fido2_credentials: None,
196 }),
197 identity: Default::default(),
198 card: Default::default(),
199 secure_note: Default::default(),
200 ssh_key: Default::default(),
201 bank_account: Default::default(),
202 drivers_license: Default::default(),
203 passport: Default::default(),
204 organization_use_totp: Default::default(),
205 edit: Default::default(),
206 permissions: Default::default(),
207 view_password: Default::default(),
208 local_data: Default::default(),
209 attachments: Default::default(),
210 password_history: Default::default(),
211 creation_date: Default::default(),
212 deleted_date: Default::default(),
213 revision_date: Default::default(),
214 archived_date: Default::default(),
215 data: Default::default(),
216 }
217 }
218
219 #[tokio::test]
220 async fn test_restore() {
221 let mut cipher_1 = generate_test_cipher();
223 cipher_1.deleted_date = Some(Utc::now());
224
225 let api_client = ApiClient::new_mocked(move |mock| {
226 mock.ciphers_api
227 .expect_put_restore()
228 .returning(move |_model| {
229 Ok(CipherResponseModel {
230 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
231 name: cipher_1.name.as_ref().map(ToString::to_string),
232 r#type: Some(cipher_1.r#type.into()),
233 creation_date: Some(cipher_1.creation_date.to_string()),
234 revision_date: Some(Utc::now().to_string()),
235 ..Default::default()
236 })
237 });
238 });
239
240 let repository: MemoryRepository<Cipher> = Default::default();
241 let store: KeyStore<KeySlotIds> = KeyStore::default();
242 #[allow(deprecated)]
243 let _ = store.context_mut().set_symmetric_key(
244 SymmetricKeySlotId::User,
245 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
246 );
247
248 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
249 let mut cipher = generate_test_cipher();
250 cipher.deleted_date = Some(Utc::now());
251 cipher.collection_ids = vec![collection_id];
252
253 repository
254 .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
255 .await
256 .unwrap();
257
258 let start_time = Utc::now();
259 let updated_cipher = restore(
260 TEST_CIPHER_ID.parse().unwrap(),
261 &api_client,
262 &repository,
263 &store,
264 false,
265 )
266 .await
267 .unwrap();
268
269 let end_time = Utc::now();
270 assert!(updated_cipher.deleted_date.is_none());
271 assert!(
272 updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
273 );
274 assert_eq!(updated_cipher.collection_ids, vec![collection_id]);
277
278 let repo_cipher = repository
279 .get(TEST_CIPHER_ID.parse().unwrap())
280 .await
281 .unwrap()
282 .unwrap();
283 assert!(repo_cipher.deleted_date.is_none());
284 assert!(
285 repo_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
286 );
287 }
288
289 #[tokio::test]
290 async fn test_restore_many() {
291 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
292 let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
293 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
294 let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
295 let mut cipher_1 = generate_test_cipher();
296 cipher_1.deleted_date = Some(Utc::now());
297 cipher_1.collection_ids = vec![collection_id];
298 let mut cipher_2 = generate_test_cipher();
299 cipher_2.deleted_date = Some(Utc::now());
300 cipher_2.id = Some(cipher_id_2);
301 cipher_2.collection_ids = vec![collection_id_2];
302
303 let api_client = {
304 let cipher_1 = cipher_1.clone();
305 let cipher_2 = cipher_2.clone();
306 ApiClient::new_mocked(move |mock| {
307 mock.ciphers_api.expect_put_restore_many().returning({
308 move |_model| {
309 Ok(CipherMiniResponseModelListResponseModel {
310 object: None,
311 data: Some(vec![
312 CipherMiniResponseModel {
313 id: cipher_1.id.map(|id| id.into()),
314 name: cipher_1.name.as_ref().map(ToString::to_string),
315 r#type: Some(cipher_1.r#type.into()),
316 login: cipher_1.login.clone().map(|l| Box::new(l.into())),
317 creation_date: cipher_1.creation_date.to_string().into(),
318 deleted_date: None,
319 revision_date: Some(Utc::now().to_string()),
320 ..Default::default()
321 },
322 CipherMiniResponseModel {
323 id: cipher_2.id.map(|id| id.into()),
324 name: cipher_2.name.as_ref().map(ToString::to_string),
325 r#type: Some(cipher_2.r#type.into()),
326 login: cipher_2.login.clone().map(|l| Box::new(l.into())),
327 creation_date: cipher_2.creation_date.to_string().into(),
328 deleted_date: None,
329 revision_date: Some(Utc::now().to_string()),
330 ..Default::default()
331 },
332 ]),
333 continuation_token: None,
334 })
335 }
336 });
337 })
338 };
339
340 let repository: MemoryRepository<Cipher> = Default::default();
341 let store: KeyStore<KeySlotIds> = KeyStore::default();
342 #[allow(deprecated)]
343 let _ = store.context_mut().set_symmetric_key(
344 SymmetricKeySlotId::User,
345 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
346 );
347
348 repository.set(cipher_id, cipher_1).await.unwrap();
349 repository.set(cipher_id_2, cipher_2).await.unwrap();
350
351 let start_time = Utc::now();
352 let ciphers = restore_many(
353 vec![cipher_id, cipher_id_2],
354 &api_client,
355 &repository,
356 &store,
357 false,
358 )
359 .await
360 .unwrap();
361 let end_time = Utc::now();
362
363 assert_eq!(ciphers.successes.len(), 2,);
364 assert_eq!(ciphers.failures.len(), 0,);
365 assert_eq!(ciphers.successes[0].deleted_date, None,);
366 assert_eq!(ciphers.successes[1].deleted_date, None,);
367
368 let cipher_1 = repository.get(cipher_id).await.unwrap().unwrap();
370 let cipher_2 = repository.get(cipher_id_2).await.unwrap().unwrap();
371 assert!(cipher_1.deleted_date.is_none());
372 assert!(cipher_2.deleted_date.is_none());
373 assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time);
374 assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time);
375 }
376
377 #[tokio::test]
378 async fn test_restore_preserves_collection_ids() {
379 let store = setup_key_store();
380 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
381
382 let mut cipher = generate_test_cipher();
383 cipher.deleted_date = Some(Utc::now());
384 cipher.collection_ids = vec![collection_id];
385
386 let cipher_name = cipher
387 .name
388 .as_ref()
389 .map(ToString::to_string)
390 .unwrap_or_default();
391 let cipher_type = cipher.r#type;
392
393 let api_client = ApiClient::new_mocked(move |mock| {
394 mock.ciphers_api.expect_put_restore().returning(move |_| {
395 Ok(CipherResponseModel {
396 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
397 name: Some(cipher_name.clone()),
398 r#type: Some(cipher_type.into()),
399 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
400 revision_date: Some(Utc::now().to_string()),
401 ..Default::default()
402 })
403 });
404 });
405
406 let repository: MemoryRepository<Cipher> = Default::default();
407 repository
408 .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
409 .await
410 .unwrap();
411
412 let result = restore(
413 TEST_CIPHER_ID.parse().unwrap(),
414 &api_client,
415 &repository,
416 &store,
417 false,
418 )
419 .await
420 .unwrap();
421
422 assert_eq!(result.collection_ids, vec![collection_id]);
425 }
426
427 #[tokio::test]
428 async fn test_restore_many_preserves_collection_ids() {
429 let store = setup_key_store();
430 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
431 let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
432 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
433 let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
434
435 let mut cipher_1 = generate_test_cipher();
436 cipher_1.deleted_date = Some(Utc::now());
437 cipher_1.collection_ids = vec![collection_id];
438
439 let mut cipher_2 = generate_test_cipher();
440 cipher_2.id = Some(cipher_id_2);
441 cipher_2.deleted_date = Some(Utc::now());
442 cipher_2.collection_ids = vec![collection_id_2];
443
444 let api_client = {
445 let cipher_1 = cipher_1.clone();
446 let cipher_2 = cipher_2.clone();
447 ApiClient::new_mocked(move |mock| {
448 mock.ciphers_api.expect_put_restore_many().returning({
449 move |_| {
450 Ok(CipherMiniResponseModelListResponseModel {
451 object: None,
452 data: Some(vec![
453 CipherMiniResponseModel {
454 id: cipher_1.id.map(|id| id.into()),
455 name: cipher_1.name.as_ref().map(ToString::to_string),
456 r#type: Some(cipher_1.r#type.into()),
457 login: cipher_1.login.clone().map(|l| Box::new(l.into())),
458 creation_date: cipher_1.creation_date.to_string().into(),
459 deleted_date: None,
460 revision_date: Some(Utc::now().to_string()),
461 ..Default::default()
462 },
463 CipherMiniResponseModel {
464 id: cipher_2.id.map(|id| id.into()),
465 name: cipher_2.name.as_ref().map(ToString::to_string),
466 r#type: Some(cipher_2.r#type.into()),
467 login: cipher_2.login.clone().map(|l| Box::new(l.into())),
468 creation_date: cipher_2.creation_date.to_string().into(),
469 deleted_date: None,
470 revision_date: Some(Utc::now().to_string()),
471 ..Default::default()
472 },
473 ]),
474 continuation_token: None,
475 })
476 }
477 });
478 })
479 };
480
481 let repository: MemoryRepository<Cipher> = Default::default();
482 repository.set(cipher_id, cipher_1).await.unwrap();
483 repository.set(cipher_id_2, cipher_2).await.unwrap();
484
485 let ciphers = restore_many(
486 vec![cipher_id, cipher_id_2],
487 &api_client,
488 &repository,
489 &store,
490 false,
491 )
492 .await
493 .unwrap();
494
495 assert_eq!(ciphers.successes.len(), 2);
496
497 let result_1 = ciphers
500 .successes
501 .iter()
502 .find(|c| c.id == Some(cipher_id))
503 .unwrap();
504 let result_2 = ciphers
505 .successes
506 .iter()
507 .find(|c| c.id == Some(cipher_id_2))
508 .unwrap();
509 assert_eq!(result_1.collection_ids, vec![collection_id]);
510 assert_eq!(result_2.collection_ids, vec![collection_id_2]);
511 }
512}