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
28pub 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
52pub 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 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 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 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 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 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 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 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}