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