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