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#[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
30/// Restores a soft-deleted cipher on the server.
31pub 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
54/// Restores multiple soft-deleted ciphers on the server.
55pub 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    /// Restores a soft-deleted cipher on the server.
110    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    /// Restores multiple soft-deleted ciphers on the server.
125    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        // Set up test ciphers in the repository.
222        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        // collection_ids are not returned by the server's restore response — they must be
275        // preserved.
276        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        // Confirm repository was updated
369        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        // collection_ids are not returned by the server's restore response — they must
423        // be preserved from the existing cipher in the repository.
424        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        // collection_ids are not returned by the server's restore response — they must
498        // be preserved from the existing ciphers in the repository.
499        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}