Skip to main content

bitwarden_vault/cipher/cipher_client/admin/
edit.rs

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::{PartialCipher, StrictDecrypt},
20    cipher_client::edit::{CipherEditRequest, convert_request_to_cipher_view},
21};
22
23#[allow(missing_docs)]
24#[bitwarden_error(flat)]
25#[derive(Debug, Error)]
26pub enum EditCipherAdminError {
27    #[error(transparent)]
28    ItemNotFound(#[from] ItemNotFoundError),
29    #[error(transparent)]
30    Crypto(#[from] CryptoError),
31    #[error(transparent)]
32    Api(#[from] ApiError),
33    #[error(transparent)]
34    VaultParse(#[from] VaultParseError),
35    #[error(transparent)]
36    MissingField(#[from] MissingFieldError),
37    #[error(transparent)]
38    NotAuthenticated(#[from] NotAuthenticatedError),
39    #[error(transparent)]
40    Repository(#[from] RepositoryError),
41    #[error(transparent)]
42    Uuid(#[from] uuid::Error),
43    #[error(transparent)]
44    Decrypt(#[from] DecryptError),
45}
46
47impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherAdminError {
48    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
49        Self::Api(val.into())
50    }
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    enable_cipher_key_encryption: bool,
61) -> Result<CipherView, EditCipherAdminError> {
62    let cipher_id = request.id;
63    // CipherMiniResponseModel does not include folder_id or favorite — save them from the
64    // request before it is consumed so they can be applied to the merged result.
65    let folder_id = request.folder_id;
66    let favorite = request.favorite;
67
68    let mut view: CipherView = convert_request_to_cipher_view(request);
69    view.update_password_history(&original_cipher_view);
70
71    // TODO: Once this flag is removed, the key generation logic should be
72    // moved directly into the CompositeEncryptable implementation.
73    if view.key.is_none() && enable_cipher_key_encryption {
74        let key = view.key_identifier();
75        view.generate_cipher_key(&mut key_store.context(), key)?;
76    }
77
78    let cipher: Cipher = key_store.encrypt(view)?;
79    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
80    cipher_request.encrypted_for = Some(encrypted_for.into());
81
82    let orig_cipher = key_store.encrypt(original_cipher_view)?;
83
84    let mut cipher: Cipher = api_client
85        .ciphers_api()
86        .put_admin(cipher_id.into(), Some(cipher_request))
87        .await
88        .map_err(ApiError::from)?
89        .merge_with_cipher(Some(orig_cipher))?;
90
91    cipher.folder_id = folder_id;
92    cipher.favorite = favorite;
93
94    if use_strict_decryption {
95        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
96    } else {
97        Ok(key_store.decrypt(&cipher)?)
98    }
99}
100
101/// Adds the cipher matched by [CipherId] to any number of collections on the server.
102pub async fn add_to_collections(
103    cipher_id: CipherId,
104    collection_ids: Vec<CollectionId>,
105    api_client: &ApiClient,
106    key_store: &KeyStore<KeySlotIds>,
107    use_strict_decryption: bool,
108) -> Result<CipherView, EditCipherAdminError> {
109    let req = CipherCollectionsRequestModel {
110        collection_ids: collection_ids
111            .into_iter()
112            .map(|id| id.to_string())
113            .collect(),
114    };
115
116    let api = api_client.ciphers_api();
117    let cipher: Cipher = api
118        .put_collections_admin(&cipher_id.to_string(), Some(req))
119        .await?
120        .merge_with_cipher(None)?;
121
122    if use_strict_decryption {
123        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
124    } else {
125        Ok(key_store.decrypt(&cipher)?)
126    }
127}
128
129#[allow(deprecated)]
130#[cfg_attr(feature = "wasm", wasm_bindgen)]
131impl CipherAdminClient {
132    /// Edit an existing [Cipher] and save it to the server.
133    pub async fn edit(
134        &self,
135        request: CipherEditRequest,
136        original_cipher_view: CipherView,
137    ) -> Result<CipherView, EditCipherAdminError> {
138        let key_store = self.client.internal.get_key_store();
139        let config = self.client.internal.get_api_configurations();
140
141        let user_id = self
142            .client
143            .internal
144            .get_user_id()
145            .ok_or(NotAuthenticatedError)?;
146
147        let enable_cipher_key_encryption =
148            self.client.flags().get().await.enable_cipher_key_encryption;
149
150        edit_cipher(
151            key_store,
152            &config.api_client,
153            user_id,
154            original_cipher_view,
155            request,
156            self.is_strict_decrypt().await,
157            enable_cipher_key_encryption,
158        )
159        .await
160    }
161
162    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
163    pub async fn update_collection(
164        &self,
165        cipher_id: CipherId,
166        collection_ids: Vec<CollectionId>,
167    ) -> Result<CipherView, EditCipherAdminError> {
168        add_to_collections(
169            cipher_id,
170            collection_ids,
171            &self.client.internal.get_api_configurations().api_client,
172            self.client.internal.get_key_store(),
173            self.is_strict_decrypt().await,
174        )
175        .await
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
182    use bitwarden_core::key_management::SymmetricKeySlotId;
183    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
184
185    use super::*;
186    use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
187
188    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
189    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
190
191    fn generate_test_cipher() -> CipherView {
192        CipherView {
193            id: Some(TEST_CIPHER_ID.parse().unwrap()),
194            organization_id: None,
195            folder_id: None,
196            collection_ids: vec![],
197            key: None,
198            name: "Test Login".to_string(),
199            notes: None,
200            r#type: CipherType::Login,
201            login: Some(LoginView {
202                username: Some("[email protected]".to_string()),
203                password: Some("password123".to_string()),
204                password_revision_date: None,
205                uris: None,
206                totp: None,
207                autofill_on_page_load: None,
208                fido2_credentials: None,
209            }),
210            identity: None,
211            card: None,
212            secure_note: None,
213            ssh_key: None,
214            bank_account: None,
215            drivers_license: None,
216            passport: None,
217            favorite: false,
218            reprompt: CipherRepromptType::None,
219            organization_use_totp: true,
220            edit: true,
221            permissions: None,
222            view_password: true,
223            local_data: None,
224            attachments: None,
225            attachment_decryption_failures: None,
226            fields: None,
227            password_history: None,
228            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
229            deleted_date: None,
230            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
231            archived_date: None,
232        }
233    }
234
235    #[tokio::test]
236    async fn test_edit_cipher() {
237        let store: KeyStore<KeySlotIds> = KeyStore::default();
238        #[allow(deprecated)]
239        let _ = store.context_mut().set_symmetric_key(
240            SymmetricKeySlotId::User,
241            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
242        );
243
244        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
245
246        let api_client = ApiClient::new_mocked(move |mock| {
247            mock.ciphers_api
248                .expect_put_admin()
249                .returning(move |_id, body| {
250                    let body = body.unwrap();
251                    Ok(CipherMiniResponseModel {
252                        object: Some("cipher".to_string()),
253                        id: Some(cipher_id.into()),
254                        name: Some(body.name),
255                        r#type: body.r#type,
256                        organization_id: body
257                            .organization_id
258                            .as_ref()
259                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
260                        reprompt: body.reprompt,
261                        key: body.key,
262                        notes: body.notes,
263                        organization_use_totp: Some(true),
264                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
265                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
266                        deleted_date: None,
267                        login: body.login,
268                        card: body.card,
269                        identity: body.identity,
270                        secure_note: body.secure_note,
271                        ssh_key: body.ssh_key,
272                        bank_account: body.bank_account,
273                        drivers_license: body.drivers_license,
274                        passport: body.passport,
275                        fields: body.fields,
276                        password_history: body.password_history,
277                        attachments: None,
278                        data: None,
279                    })
280                })
281                .once();
282        });
283
284        let folder_a: crate::FolderId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
285        let folder_b: crate::FolderId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
286
287        let mut original_cipher_view = generate_test_cipher();
288        original_cipher_view.folder_id = Some(folder_a);
289        let mut cipher_view = original_cipher_view.clone();
290        cipher_view.name = "New Cipher Name".to_string();
291        // Change folder: request carries folder_b, original has folder_a.
292        cipher_view.folder_id = Some(folder_b);
293
294        let request: CipherEditRequest = cipher_view.try_into().unwrap();
295
296        let result = edit_cipher(
297            &store,
298            &api_client,
299            TEST_USER_ID.parse().unwrap(),
300            original_cipher_view,
301            request,
302            false,
303            false,
304        )
305        .await
306        .unwrap();
307
308        assert_eq!(result.id, Some(cipher_id));
309        assert_eq!(result.name, "New Cipher Name");
310        // folder_id must come from the request, not from the original cipher.
311        assert_eq!(result.folder_id, Some(folder_b));
312    }
313
314    #[tokio::test]
315    async fn test_edit_cipher_http_error() {
316        let store: KeyStore<KeySlotIds> = KeyStore::default();
317        #[allow(deprecated)]
318        let _ = store.context_mut().set_symmetric_key(
319            SymmetricKeySlotId::User,
320            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
321        );
322
323        let api_client = ApiClient::new_mocked(move |mock| {
324            mock.ciphers_api
325                .expect_put_admin()
326                .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
327        });
328        let orig_cipher_view = generate_test_cipher();
329        let cipher_view = orig_cipher_view.clone();
330        let request: CipherEditRequest = cipher_view.try_into().unwrap();
331        let result = edit_cipher(
332            &store,
333            &api_client,
334            TEST_USER_ID.parse().unwrap(),
335            orig_cipher_view,
336            request,
337            false,
338            false,
339        )
340        .await;
341
342        assert!(result.is_err());
343        assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
344    }
345}