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