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 id: TEST_CIPHER_ID.parse().ok(),
178 name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap()),
179 r#type: crate::CipherType::Login,
180 notes: Default::default(),
181 organization_id: Default::default(),
182 folder_id: Default::default(),
183 favorite: Default::default(),
184 reprompt: Default::default(),
185 fields: Default::default(),
186 collection_ids: Default::default(),
187 key: Default::default(),
188 login: Some(Login{
189 username: None,
190 password: None,
191 password_revision_date: None,
192 uris: None, totp: None,
193 autofill_on_page_load: None,
194 fido2_credentials: None,
195 }),
196 identity: Default::default(),
197 card: Default::default(),
198 secure_note: Default::default(),
199 ssh_key: Default::default(),
200 bank_account: Default::default(),
201 drivers_license: Default::default(),
202 passport: Default::default(),
203 organization_use_totp: Default::default(),
204 edit: Default::default(),
205 permissions: Default::default(),
206 view_password: Default::default(),
207 local_data: Default::default(),
208 attachments: Default::default(),
209 password_history: Default::default(),
210 creation_date: Default::default(),
211 deleted_date: Default::default(),
212 revision_date: Default::default(),
213 archived_date: Default::default(),
214 data: Default::default(),
215 }
216 }
217
218 #[tokio::test]
219 async fn test_restore() {
220 let mut cipher_1 = generate_test_cipher();
222 cipher_1.deleted_date = Some(Utc::now());
223
224 let api_client = ApiClient::new_mocked(move |mock| {
225 mock.ciphers_api
226 .expect_put_restore()
227 .returning(move |_model| {
228 Ok(CipherResponseModel {
229 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
230 name: cipher_1.name.as_ref().map(ToString::to_string),
231 r#type: Some(cipher_1.r#type.into()),
232 creation_date: Some(cipher_1.creation_date.to_string()),
233 revision_date: Some(Utc::now().to_string()),
234 ..Default::default()
235 })
236 });
237 });
238
239 let repository: MemoryRepository<Cipher> = Default::default();
240 let store: KeyStore<KeySlotIds> = KeyStore::default();
241 #[allow(deprecated)]
242 let _ = store.context_mut().set_symmetric_key(
243 SymmetricKeySlotId::User,
244 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
245 );
246
247 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
248 let mut cipher = generate_test_cipher();
249 cipher.deleted_date = Some(Utc::now());
250 cipher.collection_ids = vec![collection_id];
251
252 repository
253 .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
254 .await
255 .unwrap();
256
257 let start_time = Utc::now();
258 let updated_cipher = restore(
259 TEST_CIPHER_ID.parse().unwrap(),
260 &api_client,
261 &repository,
262 &store,
263 false,
264 )
265 .await
266 .unwrap();
267
268 let end_time = Utc::now();
269 assert!(updated_cipher.deleted_date.is_none());
270 assert!(
271 updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
272 );
273 assert_eq!(updated_cipher.collection_ids, vec![collection_id]);
276
277 let repo_cipher = repository
278 .get(TEST_CIPHER_ID.parse().unwrap())
279 .await
280 .unwrap()
281 .unwrap();
282 assert!(repo_cipher.deleted_date.is_none());
283 assert!(
284 repo_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time
285 );
286 }
287
288 #[tokio::test]
289 async fn test_restore_many() {
290 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
291 let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
292 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
293 let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
294 let mut cipher_1 = generate_test_cipher();
295 cipher_1.deleted_date = Some(Utc::now());
296 cipher_1.collection_ids = vec![collection_id];
297 let mut cipher_2 = generate_test_cipher();
298 cipher_2.deleted_date = Some(Utc::now());
299 cipher_2.id = Some(cipher_id_2);
300 cipher_2.collection_ids = vec![collection_id_2];
301
302 let api_client = {
303 let cipher_1 = cipher_1.clone();
304 let cipher_2 = cipher_2.clone();
305 ApiClient::new_mocked(move |mock| {
306 mock.ciphers_api.expect_put_restore_many().returning({
307 move |_model| {
308 Ok(CipherMiniResponseModelListResponseModel {
309 object: None,
310 data: Some(vec![
311 CipherMiniResponseModel {
312 id: cipher_1.id.map(|id| id.into()),
313 name: cipher_1.name.as_ref().map(ToString::to_string),
314 r#type: Some(cipher_1.r#type.into()),
315 login: cipher_1.login.clone().map(|l| Box::new(l.into())),
316 creation_date: cipher_1.creation_date.to_string().into(),
317 deleted_date: None,
318 revision_date: Some(Utc::now().to_string()),
319 ..Default::default()
320 },
321 CipherMiniResponseModel {
322 id: cipher_2.id.map(|id| id.into()),
323 name: cipher_2.name.as_ref().map(ToString::to_string),
324 r#type: Some(cipher_2.r#type.into()),
325 login: cipher_2.login.clone().map(|l| Box::new(l.into())),
326 creation_date: cipher_2.creation_date.to_string().into(),
327 deleted_date: None,
328 revision_date: Some(Utc::now().to_string()),
329 ..Default::default()
330 },
331 ]),
332 continuation_token: None,
333 })
334 }
335 });
336 })
337 };
338
339 let repository: MemoryRepository<Cipher> = Default::default();
340 let store: KeyStore<KeySlotIds> = KeyStore::default();
341 #[allow(deprecated)]
342 let _ = store.context_mut().set_symmetric_key(
343 SymmetricKeySlotId::User,
344 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
345 );
346
347 repository.set(cipher_id, cipher_1).await.unwrap();
348 repository.set(cipher_id_2, cipher_2).await.unwrap();
349
350 let start_time = Utc::now();
351 let ciphers = restore_many(
352 vec![cipher_id, cipher_id_2],
353 &api_client,
354 &repository,
355 &store,
356 false,
357 )
358 .await
359 .unwrap();
360 let end_time = Utc::now();
361
362 assert_eq!(ciphers.successes.len(), 2,);
363 assert_eq!(ciphers.failures.len(), 0,);
364 assert_eq!(ciphers.successes[0].deleted_date, None,);
365 assert_eq!(ciphers.successes[1].deleted_date, None,);
366
367 let cipher_1 = repository.get(cipher_id).await.unwrap().unwrap();
369 let cipher_2 = repository.get(cipher_id_2).await.unwrap().unwrap();
370 assert!(cipher_1.deleted_date.is_none());
371 assert!(cipher_2.deleted_date.is_none());
372 assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time);
373 assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time);
374 }
375
376 #[tokio::test]
377 async fn test_restore_preserves_collection_ids() {
378 let store = setup_key_store();
379 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
380
381 let mut cipher = generate_test_cipher();
382 cipher.deleted_date = Some(Utc::now());
383 cipher.collection_ids = vec![collection_id];
384
385 let cipher_name = cipher
386 .name
387 .as_ref()
388 .map(ToString::to_string)
389 .unwrap_or_default();
390 let cipher_type = cipher.r#type;
391
392 let api_client = ApiClient::new_mocked(move |mock| {
393 mock.ciphers_api.expect_put_restore().returning(move |_| {
394 Ok(CipherResponseModel {
395 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
396 name: Some(cipher_name.clone()),
397 r#type: Some(cipher_type.into()),
398 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
399 revision_date: Some(Utc::now().to_string()),
400 ..Default::default()
401 })
402 });
403 });
404
405 let repository: MemoryRepository<Cipher> = Default::default();
406 repository
407 .set(TEST_CIPHER_ID.parse().unwrap(), cipher)
408 .await
409 .unwrap();
410
411 let result = restore(
412 TEST_CIPHER_ID.parse().unwrap(),
413 &api_client,
414 &repository,
415 &store,
416 false,
417 )
418 .await
419 .unwrap();
420
421 assert_eq!(result.collection_ids, vec![collection_id]);
424 }
425
426 #[tokio::test]
427 async fn test_restore_many_preserves_collection_ids() {
428 let store = setup_key_store();
429 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
430 let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
431 let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
432 let collection_id_2: CollectionId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
433
434 let mut cipher_1 = generate_test_cipher();
435 cipher_1.deleted_date = Some(Utc::now());
436 cipher_1.collection_ids = vec![collection_id];
437
438 let mut cipher_2 = generate_test_cipher();
439 cipher_2.id = Some(cipher_id_2);
440 cipher_2.deleted_date = Some(Utc::now());
441 cipher_2.collection_ids = vec![collection_id_2];
442
443 let api_client = {
444 let cipher_1 = cipher_1.clone();
445 let cipher_2 = cipher_2.clone();
446 ApiClient::new_mocked(move |mock| {
447 mock.ciphers_api.expect_put_restore_many().returning({
448 move |_| {
449 Ok(CipherMiniResponseModelListResponseModel {
450 object: None,
451 data: Some(vec![
452 CipherMiniResponseModel {
453 id: cipher_1.id.map(|id| id.into()),
454 name: cipher_1.name.as_ref().map(ToString::to_string),
455 r#type: Some(cipher_1.r#type.into()),
456 login: cipher_1.login.clone().map(|l| Box::new(l.into())),
457 creation_date: cipher_1.creation_date.to_string().into(),
458 deleted_date: None,
459 revision_date: Some(Utc::now().to_string()),
460 ..Default::default()
461 },
462 CipherMiniResponseModel {
463 id: cipher_2.id.map(|id| id.into()),
464 name: cipher_2.name.as_ref().map(ToString::to_string),
465 r#type: Some(cipher_2.r#type.into()),
466 login: cipher_2.login.clone().map(|l| Box::new(l.into())),
467 creation_date: cipher_2.creation_date.to_string().into(),
468 deleted_date: None,
469 revision_date: Some(Utc::now().to_string()),
470 ..Default::default()
471 },
472 ]),
473 continuation_token: None,
474 })
475 }
476 });
477 })
478 };
479
480 let repository: MemoryRepository<Cipher> = Default::default();
481 repository.set(cipher_id, cipher_1).await.unwrap();
482 repository.set(cipher_id_2, cipher_2).await.unwrap();
483
484 let ciphers = restore_many(
485 vec![cipher_id, cipher_id_2],
486 &api_client,
487 &repository,
488 &store,
489 false,
490 )
491 .await
492 .unwrap();
493
494 assert_eq!(ciphers.successes.len(), 2);
495
496 let result_1 = ciphers
499 .successes
500 .iter()
501 .find(|c| c.id == Some(cipher_id))
502 .unwrap();
503 let result_2 = ciphers
504 .successes
505 .iter()
506 .find(|c| c.id == Some(cipher_id_2))
507 .unwrap();
508 assert_eq!(result_1.collection_ids, vec![collection_id]);
509 assert_eq!(result_2.collection_ids, vec![collection_id_2]);
510 }
511}