Skip to main content

bitwarden_vault/cipher/cipher_client/
create.rs

1use bitwarden_api_api::models::{CipherCreateRequestModel, CipherRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
5    key_management::KeySlotIds, require,
6};
7use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
8use bitwarden_error::bitwarden_error;
9use bitwarden_state::repository::{Repository, RepositoryError};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13#[cfg(feature = "wasm")]
14use tsify::Tsify;
15#[cfg(feature = "wasm")]
16use wasm_bindgen::prelude::*;
17
18use super::CiphersClient;
19use crate::{
20    Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
21    cipher::cipher::PartialCipher, cipher_view_type::CipherViewType,
22};
23
24#[allow(missing_docs)]
25#[bitwarden_error(flat)]
26#[derive(Debug, Error)]
27pub enum CreateCipherError {
28    #[error(transparent)]
29    Crypto(#[from] CryptoError),
30    #[error(transparent)]
31    Api(#[from] ApiError),
32    #[error(transparent)]
33    VaultParse(#[from] VaultParseError),
34    #[error(transparent)]
35    MissingField(#[from] MissingFieldError),
36    #[error(transparent)]
37    NotAuthenticated(#[from] NotAuthenticatedError),
38    #[error(transparent)]
39    Repository(#[from] RepositoryError),
40}
41
42impl<T> From<bitwarden_api_api::apis::Error<T>> for CreateCipherError {
43    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
44        Self::Api(val.into())
45    }
46}
47
48/// Request to add a cipher.
49#[derive(Serialize, Deserialize, Clone, Debug)]
50#[serde(rename_all = "camelCase")]
51#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
52#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
53pub struct CipherCreateRequest {
54    pub organization_id: Option<OrganizationId>,
55    pub collection_ids: Vec<CollectionId>,
56    pub folder_id: Option<FolderId>,
57    pub name: String,
58    pub notes: Option<String>,
59    pub favorite: bool,
60    pub reprompt: CipherRepromptType,
61    pub r#type: CipherViewType,
62    pub fields: Vec<FieldView>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub archived_date: Option<DateTime<Utc>>,
65}
66
67/// Internal helper to convert a [`CipherCreateRequest`] into a [`CipherView`]
68/// so the existing `CipherView` encryption pipeline can be reused.
69///
70/// This conversion is lossy and intended for use only within the internal create flow.
71/// Placeholder values are generated to satisfy the CipherView contract; they have
72/// no meaning outside of this flow.
73pub(crate) fn convert_request_to_cipher_view(r: CipherCreateRequest) -> CipherView {
74    // `creation_date` / `revision_date` are overwritten by the server on
75    // merge; `Utc::now()` is a safe placeholder.
76    let now = chrono::Utc::now();
77    CipherView {
78        id: None,
79        organization_id: r.organization_id,
80        folder_id: r.folder_id,
81        collection_ids: r.collection_ids,
82        key: None,
83        name: r.name,
84        notes: r.notes,
85        r#type: r.r#type.get_cipher_type(),
86        login: r.r#type.as_login_view().cloned(),
87        identity: r.r#type.as_identity_view().cloned(),
88        card: r.r#type.as_card_view().cloned(),
89        secure_note: r.r#type.as_secure_note_view().cloned(),
90        ssh_key: r.r#type.as_ssh_key_view().cloned(),
91        bank_account: r.r#type.as_bank_account_view().cloned(),
92        drivers_license: r.r#type.as_drivers_license_view().cloned(),
93        passport: r.r#type.as_passport_view().cloned(),
94        favorite: r.favorite,
95        reprompt: r.reprompt,
96        organization_use_totp: false,
97        edit: true,
98        permissions: None,
99        view_password: true,
100        local_data: None,
101        attachments: None,
102        attachment_decryption_failures: None,
103        fields: Some(r.fields),
104        password_history: None,
105        creation_date: now,
106        deleted_date: None,
107        revision_date: now,
108        archived_date: r.archived_date,
109    }
110}
111
112async fn create_cipher<R: Repository<Cipher> + ?Sized>(
113    key_store: &KeyStore<KeySlotIds>,
114    api_client: &bitwarden_api_api::apis::ApiClient,
115    repository: &R,
116    encrypted_for: UserId,
117    view: CipherView,
118) -> Result<CipherView, CreateCipherError> {
119    let collection_ids = view.collection_ids.clone();
120
121    let cipher: Cipher = key_store.encrypt(view)?;
122    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
123    cipher_request.encrypted_for = Some(encrypted_for.into());
124
125    let mut cipher: Cipher;
126    if !collection_ids.is_empty() {
127        cipher = api_client
128            .ciphers_api()
129            .post_create(Some(CipherCreateRequestModel {
130                collection_ids: Some(collection_ids.iter().cloned().map(Into::into).collect()),
131                cipher: Box::new(cipher_request),
132            }))
133            .await
134            .map_err(ApiError::from)?
135            .merge_with_cipher(None)?;
136        cipher.collection_ids = collection_ids;
137        repository.set(require!(cipher.id), cipher.clone()).await?;
138    } else {
139        cipher = api_client
140            .ciphers_api()
141            .post(Some(cipher_request))
142            .await
143            .map_err(ApiError::from)?
144            .merge_with_cipher(None)?;
145        repository.set(require!(cipher.id), cipher.clone()).await?;
146    }
147
148    Ok(key_store.decrypt(&cipher)?)
149}
150
151#[allow(deprecated)]
152#[cfg_attr(feature = "wasm", wasm_bindgen)]
153impl CiphersClient {
154    /// Creates a new [Cipher] and saves it to the server.
155    pub async fn create(
156        &self,
157        request: CipherCreateRequest,
158    ) -> Result<CipherView, CreateCipherError> {
159        let key_store = self.client.internal.get_key_store();
160        let config = self.client.internal.get_api_configurations();
161        let repository = self.get_repository()?;
162
163        let user_id = self
164            .client
165            .internal
166            .get_user_id()
167            .ok_or(NotAuthenticatedError)?;
168
169        let mut view: CipherView = convert_request_to_cipher_view(request);
170
171        // TODO: Once this flag is removed, the key generation logic should
172        // be moved directly into the CompositeEncryptable implementation.
173        if self.client.flags().get().await.enable_cipher_key_encryption {
174            let key = view.key_identifier();
175            view.generate_cipher_key(&mut key_store.context(), key)?;
176        }
177
178        create_cipher(
179            key_store,
180            &config.api_client,
181            repository.as_ref(),
182            user_id,
183            view,
184        )
185        .await
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
192    use bitwarden_core::key_management::SymmetricKeySlotId;
193    use bitwarden_crypto::SymmetricKeyAlgorithm;
194    use bitwarden_test::MemoryRepository;
195    use chrono::Utc;
196
197    use super::*;
198    use crate::{CipherId, LoginView};
199
200    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
201    const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd";
202    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
203    const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
204
205    fn generate_test_cipher_create_request() -> CipherCreateRequest {
206        CipherCreateRequest {
207            name: "Test Login".to_string(),
208            notes: Some("Test notes".to_string()),
209            r#type: CipherViewType::Login(LoginView {
210                username: Some("[email protected]".to_string()),
211                password: Some("password123".to_string()),
212                password_revision_date: None,
213                uris: None,
214                totp: None,
215                autofill_on_page_load: None,
216                fido2_credentials: None,
217            }),
218            organization_id: Default::default(),
219            folder_id: Default::default(),
220            favorite: Default::default(),
221            reprompt: Default::default(),
222            fields: Default::default(),
223            collection_ids: vec![],
224            archived_date: None,
225        }
226    }
227
228    #[tokio::test]
229    async fn test_create_cipher() {
230        let store: KeyStore<KeySlotIds> = KeyStore::default();
231        {
232            let mut ctx = store.context_mut();
233            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
234            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
235                .unwrap();
236        }
237
238        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
239
240        let api_client = ApiClient::new_mocked(move |mock| {
241            mock.ciphers_api
242                .expect_post()
243                .returning(move |body| {
244                    let body = body.unwrap();
245                    Ok(CipherResponseModel {
246                        object: Some("cipher".to_string()),
247                        id: Some(cipher_id.into()),
248                        name: Some(body.name.clone()),
249                        r#type: body.r#type,
250                        organization_id: body
251                            .organization_id
252                            .as_ref()
253                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
254                        folder_id: body
255                            .folder_id
256                            .as_ref()
257                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
258                        favorite: body.favorite,
259                        reprompt: body.reprompt,
260                        key: body.key.clone(),
261                        notes: body.notes.clone(),
262                        view_password: Some(true),
263                        edit: Some(true),
264                        organization_use_totp: Some(true),
265                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
266                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
267                        deleted_date: None,
268                        login: body.login,
269                        card: body.card,
270                        identity: body.identity,
271                        secure_note: body.secure_note,
272                        ssh_key: body.ssh_key,
273                        bank_account: body.bank_account,
274                        drivers_license: body.drivers_license,
275                        passport: body.passport,
276                        fields: body.fields,
277                        password_history: body.password_history,
278                        attachments: None,
279                        permissions: None,
280                        data: None,
281                        archived_date: None,
282                    })
283                })
284                .once();
285        });
286
287        let repository = MemoryRepository::<Cipher>::default();
288        let request = generate_test_cipher_create_request();
289
290        let result = create_cipher(
291            &store,
292            &api_client,
293            &repository,
294            TEST_USER_ID.parse().unwrap(),
295            convert_request_to_cipher_view(request),
296        )
297        .await
298        .unwrap();
299
300        assert_eq!(result.id, Some(cipher_id));
301        assert_eq!(result.name, "Test Login");
302        assert_eq!(
303            result.login,
304            Some(LoginView {
305                username: Some("[email protected]".to_string()),
306                password: Some("password123".to_string()),
307                password_revision_date: None,
308                uris: None,
309                totp: None,
310                autofill_on_page_load: None,
311                fido2_credentials: None,
312            })
313        );
314
315        // Confirm the cipher was stored in the repository
316        let stored_cipher_view: CipherView = store
317            .decrypt(&repository.get(cipher_id).await.unwrap().unwrap())
318            .unwrap();
319        assert_eq!(stored_cipher_view.id, result.id);
320        assert_eq!(stored_cipher_view.name, result.name);
321        assert_eq!(stored_cipher_view.r#type, result.r#type);
322        assert!(stored_cipher_view.login.is_some());
323        assert_eq!(stored_cipher_view.favorite, result.favorite);
324    }
325
326    #[tokio::test]
327    async fn test_create_cipher_http_error() {
328        let store: KeyStore<KeySlotIds> = KeyStore::default();
329        {
330            let mut ctx = store.context_mut();
331            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
332            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
333                .unwrap();
334        }
335
336        let api_client = ApiClient::new_mocked(move |mock| {
337            mock.ciphers_api
338                .expect_post()
339                .returning(move |_body| Err(std::io::Error::other("Simulated error").into()));
340        });
341
342        let repository = MemoryRepository::<Cipher>::default();
343
344        let request = generate_test_cipher_create_request();
345
346        let result = create_cipher(
347            &store,
348            &api_client,
349            &repository,
350            TEST_USER_ID.parse().unwrap(),
351            convert_request_to_cipher_view(request),
352        )
353        .await;
354
355        assert!(result.is_err());
356        assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_)));
357    }
358
359    #[tokio::test]
360    async fn test_create_org_cipher() {
361        let api_client = ApiClient::new_mocked(move |mock| {
362            mock.ciphers_api
363                .expect_post_create()
364                .returning(move |body| {
365                    let request_body = body.unwrap();
366
367                    Ok(CipherResponseModel {
368                        id: Some(TEST_CIPHER_ID.try_into().unwrap()),
369                        organization_id: request_body
370                            .cipher
371                            .organization_id
372                            .and_then(|id| id.parse().ok()),
373                        name: Some(request_body.cipher.name.clone()),
374                        r#type: request_body.cipher.r#type,
375                        creation_date: Some(Utc::now().to_string()),
376                        revision_date: Some(Utc::now().to_string()),
377                        ..Default::default()
378                    })
379                })
380                .once();
381        });
382
383        let store: KeyStore<KeySlotIds> = KeyStore::default();
384        {
385            let mut ctx = store.context_mut();
386            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
387            ctx.persist_symmetric_key(
388                local_key_id,
389                SymmetricKeySlotId::Organization(TEST_ORG_ID.parse().unwrap()),
390            )
391            .unwrap();
392        }
393        let repository = MemoryRepository::<Cipher>::default();
394        let request = CipherCreateRequest {
395            organization_id: Some(TEST_ORG_ID.parse().unwrap()),
396            collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()],
397            folder_id: None,
398            name: "Test Cipher".into(),
399            notes: None,
400            favorite: false,
401            reprompt: CipherRepromptType::None,
402            r#type: CipherViewType::Login(LoginView {
403                username: None,
404                password: None,
405                password_revision_date: None,
406                uris: None,
407                totp: None,
408                autofill_on_page_load: None,
409                fido2_credentials: None,
410            }),
411            fields: vec![],
412            archived_date: None,
413        };
414
415        let response = create_cipher(
416            &store,
417            &api_client,
418            &repository,
419            TEST_USER_ID.parse().unwrap(),
420            convert_request_to_cipher_view(request),
421        )
422        .await
423        .unwrap();
424
425        let cipher: Cipher = repository
426            .get(TEST_CIPHER_ID.parse().unwrap())
427            .await
428            .unwrap()
429            .unwrap();
430        let cipher_view: CipherView = store.decrypt(&cipher).unwrap();
431
432        assert_eq!(response.id, cipher_view.id);
433        assert_eq!(response.organization_id, cipher_view.organization_id);
434
435        assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap()));
436        assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap()));
437    }
438}