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