Skip to main content

bitwarden_vault/cipher/cipher_client/
restore.rs

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