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