1use bitwarden_api_api::{
2 apis::ApiClient,
3 models::{CipherCollectionsRequestModel, CipherRequestModel},
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{
7 ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeySlotIds,
8};
9use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::RepositoryError;
12use thiserror::Error;
13#[cfg(feature = "wasm")]
14use wasm_bindgen::prelude::*;
15
16use super::CipherAdminClient;
17use crate::{
18 Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError,
19 cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
20 cipher_client::{
21 edit::{CipherEditRequest, convert_request_to_cipher_view},
22 should_use_blob_encryption,
23 },
24};
25
26#[allow(missing_docs)]
27#[bitwarden_error(flat)]
28#[derive(Debug, Error)]
29pub enum EditCipherAdminError {
30 #[error(transparent)]
31 ItemNotFound(#[from] ItemNotFoundError),
32 #[error(transparent)]
33 Crypto(#[from] CryptoError),
34 #[error(transparent)]
35 Api(#[from] ApiError),
36 #[error(transparent)]
37 VaultParse(#[from] VaultParseError),
38 #[error(transparent)]
39 MissingField(#[from] MissingFieldError),
40 #[error(transparent)]
41 NotAuthenticated(#[from] NotAuthenticatedError),
42 #[error(transparent)]
43 Repository(#[from] RepositoryError),
44 #[error(transparent)]
45 Uuid(#[from] uuid::Error),
46 #[error(transparent)]
47 Decrypt(#[from] DecryptError),
48}
49
50impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherAdminError {
51 fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
52 Self::Api(val.into())
53 }
54}
55
56#[allow(clippy::too_many_arguments)]
60async fn edit_cipher(
61 key_store: &KeyStore<KeySlotIds>,
62 api_client: &bitwarden_api_api::apis::ApiClient,
63 encrypted_for: UserId,
64 original_cipher_view: CipherView,
65 request: CipherEditRequest,
66 use_strict_decryption: bool,
67 enable_cipher_key_encryption: bool,
68 use_blob: bool,
69) -> Result<CipherView, EditCipherAdminError> {
70 let cipher_id = request.id;
71 let folder_id = request.folder_id;
74 let favorite = request.favorite;
75
76 let mut view: CipherView = convert_request_to_cipher_view(request);
77 view.update_password_history(&original_cipher_view);
78
79 if view.key.is_none() && enable_cipher_key_encryption {
82 let key = view.key_identifier();
83 view.generate_cipher_key(&mut key_store.context(), key)?;
84 }
85
86 let mode = if use_blob {
92 EncryptMode::Blob(view)
93 } else {
94 EncryptMode::Legacy(view)
95 };
96 let cipher: Cipher = key_store.encrypt(mode)?;
97 let mut cipher_request: CipherRequestModel = cipher.try_into()?;
98 cipher_request.encrypted_for = Some(encrypted_for.into());
99
100 let orig_mode = if use_blob {
101 EncryptMode::Blob(original_cipher_view)
102 } else {
103 EncryptMode::Legacy(original_cipher_view)
104 };
105 let orig_cipher = key_store.encrypt(orig_mode)?;
106
107 let mut cipher: Cipher = api_client
108 .ciphers_api()
109 .put_admin(cipher_id.into(), Some(cipher_request))
110 .await
111 .map_err(ApiError::from)?
112 .merge_with_cipher(Some(orig_cipher))?;
113
114 cipher.folder_id = folder_id;
115 cipher.favorite = favorite;
116
117 Ok(if use_strict_decryption {
118 key_store.decrypt(&StrictDecrypt(cipher))?
119 } else {
120 key_store.decrypt(&cipher)?
121 })
122}
123
124pub async fn add_to_collections(
126 cipher_id: CipherId,
127 collection_ids: Vec<CollectionId>,
128 api_client: &ApiClient,
129 key_store: &KeyStore<KeySlotIds>,
130 use_strict_decryption: bool,
131) -> Result<CipherView, EditCipherAdminError> {
132 let req = CipherCollectionsRequestModel {
133 collection_ids: collection_ids
134 .into_iter()
135 .map(|id| id.to_string())
136 .collect(),
137 };
138
139 let api = api_client.ciphers_api();
140 let cipher: Cipher = api
141 .put_collections_admin(&cipher_id.to_string(), Some(req))
142 .await?
143 .merge_with_cipher(None)?;
144
145 Ok(if use_strict_decryption {
146 key_store.decrypt(&StrictDecrypt(cipher))?
147 } else {
148 key_store.decrypt(&cipher)?
149 })
150}
151
152#[allow(deprecated)]
153#[cfg_attr(feature = "wasm", wasm_bindgen)]
154impl CipherAdminClient {
155 pub async fn edit(
157 &self,
158 request: CipherEditRequest,
159 original_cipher_view: CipherView,
160 ) -> Result<CipherView, EditCipherAdminError> {
161 let key_store = self.client.internal.get_key_store();
162 let config = self.client.internal.get_api_configurations();
163
164 let user_id = self
165 .client
166 .internal
167 .get_user_id()
168 .ok_or(NotAuthenticatedError)?;
169
170 let enable_cipher_key_encryption =
171 self.client.flags().get().await.enable_cipher_key_encryption;
172
173 let use_blob = should_use_blob_encryption(&self.client, request.organization_id);
174
175 edit_cipher(
176 key_store,
177 &config.api_client,
178 user_id,
179 original_cipher_view,
180 request,
181 self.is_strict_decrypt().await,
182 enable_cipher_key_encryption,
183 use_blob,
184 )
185 .await
186 }
187
188 pub async fn update_collection(
190 &self,
191 cipher_id: CipherId,
192 collection_ids: Vec<CollectionId>,
193 ) -> Result<CipherView, EditCipherAdminError> {
194 add_to_collections(
195 cipher_id,
196 collection_ids,
197 &self.client.internal.get_api_configurations().api_client,
198 self.client.internal.get_key_store(),
199 self.is_strict_decrypt().await,
200 )
201 .await
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
208 use bitwarden_core::key_management::SymmetricKeySlotId;
209 use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
210
211 use super::*;
212 use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
213
214 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
215 const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
216
217 fn generate_test_cipher() -> CipherView {
218 CipherView {
219 id: Some(TEST_CIPHER_ID.parse().unwrap()),
220 organization_id: None,
221 folder_id: None,
222 collection_ids: vec![],
223 key: None,
224 name: "Test Login".to_string(),
225 notes: None,
226 r#type: CipherType::Login,
227 login: Some(LoginView {
228 username: Some("[email protected]".to_string()),
229 password: Some("password123".to_string()),
230 password_revision_date: None,
231 uris: None,
232 totp: None,
233 autofill_on_page_load: None,
234 fido2_credentials: None,
235 }),
236 identity: None,
237 card: None,
238 secure_note: None,
239 ssh_key: None,
240 bank_account: None,
241 drivers_license: None,
242 passport: None,
243 favorite: false,
244 reprompt: CipherRepromptType::None,
245 organization_use_totp: true,
246 edit: true,
247 permissions: None,
248 view_password: true,
249 local_data: None,
250 attachments: None,
251 attachment_decryption_failures: None,
252 fields: None,
253 password_history: None,
254 creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
255 deleted_date: None,
256 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
257 archived_date: None,
258 }
259 }
260
261 #[tokio::test]
262 async fn test_edit_cipher() {
263 let store: KeyStore<KeySlotIds> = KeyStore::default();
264 #[allow(deprecated)]
265 let _ = store.context_mut().set_symmetric_key(
266 SymmetricKeySlotId::User,
267 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
268 );
269
270 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
271
272 let api_client = ApiClient::new_mocked(move |mock| {
273 mock.ciphers_api
274 .expect_put_admin()
275 .returning(move |_id, body| {
276 let body = body.unwrap();
277 Ok(CipherMiniResponseModel {
278 object: Some("cipher".to_string()),
279 id: Some(cipher_id.into()),
280 name: Some(body.name),
281 r#type: body.r#type,
282 organization_id: body
283 .organization_id
284 .as_ref()
285 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
286 reprompt: body.reprompt,
287 key: body.key,
288 notes: body.notes,
289 organization_use_totp: Some(true),
290 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
291 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
292 deleted_date: None,
293 login: body.login,
294 card: body.card,
295 identity: body.identity,
296 secure_note: body.secure_note,
297 ssh_key: body.ssh_key,
298 bank_account: body.bank_account,
299 drivers_license: body.drivers_license,
300 passport: body.passport,
301 fields: body.fields,
302 password_history: body.password_history,
303 attachments: None,
304 data: None,
305 })
306 })
307 .once();
308 });
309
310 let folder_a: crate::FolderId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
311 let folder_b: crate::FolderId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
312
313 let mut original_cipher_view = generate_test_cipher();
314 original_cipher_view.folder_id = Some(folder_a);
315 let mut cipher_view = original_cipher_view.clone();
316 cipher_view.name = "New Cipher Name".to_string();
317 cipher_view.folder_id = Some(folder_b);
319
320 let request: CipherEditRequest = cipher_view.try_into().unwrap();
321
322 let result = edit_cipher(
323 &store,
324 &api_client,
325 TEST_USER_ID.parse().unwrap(),
326 original_cipher_view,
327 request,
328 false,
329 false,
330 false,
331 )
332 .await
333 .unwrap();
334
335 assert_eq!(result.id, Some(cipher_id));
336 assert_eq!(result.name, "New Cipher Name");
337 assert_eq!(result.folder_id, Some(folder_b));
339 }
340
341 #[tokio::test]
344 async fn test_edit_cipher_blob_uses_echoed_data() {
345 let store: KeyStore<KeySlotIds> = KeyStore::default();
346 #[allow(deprecated)]
347 let _ = store.context_mut().set_symmetric_key(
348 SymmetricKeySlotId::User,
349 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
350 );
351
352 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
353
354 let api_client = ApiClient::new_mocked(move |mock| {
356 mock.ciphers_api
357 .expect_put_admin()
358 .returning(move |_id, body| {
359 let body = body.unwrap();
360 Ok(CipherMiniResponseModel {
361 id: Some(cipher_id.into()),
362 r#type: body.r#type,
363 key: body.key,
364 data: body.data,
365 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
366 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
367 ..Default::default()
368 })
369 })
370 .once();
371 });
372
373 let original_cipher_view = generate_test_cipher();
374 let mut cipher_view = original_cipher_view.clone();
375 cipher_view.name = "New Cipher Name".to_string();
376
377 let request: CipherEditRequest = cipher_view.try_into().unwrap();
378
379 let result = edit_cipher(
380 &store,
381 &api_client,
382 TEST_USER_ID.parse().unwrap(),
383 original_cipher_view,
384 request,
385 false,
386 false,
387 true, )
389 .await
390 .unwrap();
391
392 assert_eq!(result.name, "New Cipher Name");
395 }
396
397 #[tokio::test]
398 async fn test_edit_cipher_http_error() {
399 let store: KeyStore<KeySlotIds> = KeyStore::default();
400 #[allow(deprecated)]
401 let _ = store.context_mut().set_symmetric_key(
402 SymmetricKeySlotId::User,
403 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
404 );
405
406 let api_client = ApiClient::new_mocked(move |mock| {
407 mock.ciphers_api
408 .expect_put_admin()
409 .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
410 });
411 let orig_cipher_view = generate_test_cipher();
412 let cipher_view = orig_cipher_view.clone();
413 let request: CipherEditRequest = cipher_view.try_into().unwrap();
414 let result = edit_cipher(
415 &store,
416 &api_client,
417 TEST_USER_ID.parse().unwrap(),
418 orig_cipher_view,
419 request,
420 false,
421 false,
422 false,
423 )
424 .await;
425
426 assert!(result.is_err());
427 assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
428 }
429}