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, SymmetricKeySlotId},
6    require,
7};
8use bitwarden_crypto::{
9    CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext,
10    PrimitiveEncryptable,
11};
12use bitwarden_error::bitwarden_error;
13use bitwarden_state::repository::{Repository, RepositoryError};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16#[cfg(feature = "wasm")]
17use tsify::Tsify;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use super::CiphersClient;
22use crate::{
23    Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
24    cipher::cipher::PartialCipher, cipher_view_type::CipherViewType,
25};
26
27#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error)]
30pub enum CreateCipherError {
31    #[error(transparent)]
32    Crypto(#[from] CryptoError),
33    #[error(transparent)]
34    Api(#[from] ApiError),
35    #[error(transparent)]
36    VaultParse(#[from] VaultParseError),
37    #[error(transparent)]
38    MissingField(#[from] MissingFieldError),
39    #[error(transparent)]
40    NotAuthenticated(#[from] NotAuthenticatedError),
41    #[error(transparent)]
42    Repository(#[from] RepositoryError),
43}
44
45impl<T> From<bitwarden_api_api::apis::Error<T>> for CreateCipherError {
46    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
47        Self::Api(val.into())
48    }
49}
50
51/// Request to add a cipher.
52#[derive(Serialize, Deserialize, Clone, Debug)]
53#[serde(rename_all = "camelCase")]
54#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
55#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
56pub struct CipherCreateRequest {
57    pub organization_id: Option<OrganizationId>,
58    pub collection_ids: Vec<CollectionId>,
59    pub folder_id: Option<FolderId>,
60    pub name: String,
61    pub notes: Option<String>,
62    pub favorite: bool,
63    pub reprompt: CipherRepromptType,
64    pub r#type: CipherViewType,
65    pub fields: Vec<FieldView>,
66}
67
68/// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted
69/// value. This allows us to manage the cipher key creation internally.
70#[derive(Clone, Debug)]
71pub(super) struct CipherCreateRequestInternal {
72    pub(super) create_request: CipherCreateRequest,
73    key: Option<EncString>,
74}
75
76impl From<CipherCreateRequest> for CipherCreateRequestInternal {
77    fn from(create_request: CipherCreateRequest) -> Self {
78        Self {
79            create_request,
80            key: None,
81        }
82    }
83}
84
85impl CipherCreateRequestInternal {
86    /// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the
87    /// encrypted key to the cipher data.
88    pub(crate) fn generate_cipher_key(
89        &mut self,
90        ctx: &mut KeyStoreContext<KeySlotIds>,
91        key: SymmetricKeySlotId,
92    ) -> Result<(), CryptoError> {
93        let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
94
95        let new_key = ctx.generate_symmetric_key();
96        self.create_request
97            .r#type
98            .as_login_view_mut()
99            .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key))
100            .transpose()?;
101
102        self.key = Some(ctx.wrap_symmetric_key(key, new_key)?);
103        Ok(())
104    }
105
106    fn generate_checksums(&mut self) {
107        if let Some(login) = &mut self.create_request.r#type.as_login_view_mut() {
108            login.generate_checksums();
109        }
110    }
111}
112
113impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, CipherRequestModel>
114    for CipherCreateRequestInternal
115{
116    fn encrypt_composite(
117        &self,
118        ctx: &mut KeyStoreContext<KeySlotIds>,
119        key: SymmetricKeySlotId,
120    ) -> Result<CipherRequestModel, CryptoError> {
121        // Clone self so we can generating the checksums before encrypting.
122        let mut cipher_data = (*self).clone();
123        cipher_data.generate_checksums();
124
125        let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &cipher_data.key)?;
126
127        let cipher_request = CipherRequestModel {
128            encrypted_for: None,
129            r#type: Some(cipher_data.create_request.r#type.get_cipher_type().into()),
130            organization_id: cipher_data
131                .create_request
132                .organization_id
133                .map(|id| id.to_string()),
134            folder_id: cipher_data
135                .create_request
136                .folder_id
137                .map(|id| id.to_string()),
138            favorite: Some(cipher_data.create_request.favorite),
139            reprompt: Some(cipher_data.create_request.reprompt.into()),
140            key: cipher_data.key.map(|k| k.to_string()),
141            name: cipher_data
142                .create_request
143                .name
144                .encrypt(ctx, cipher_key)?
145                .to_string(),
146            notes: cipher_data
147                .create_request
148                .notes
149                .as_ref()
150                .map(|n| n.encrypt(ctx, cipher_key))
151                .transpose()?
152                .map(|n| n.to_string()),
153            login: cipher_data
154                .create_request
155                .r#type
156                .as_login_view()
157                .as_ref()
158                .map(|l| l.encrypt_composite(ctx, cipher_key))
159                .transpose()?
160                .map(|l| Box::new(l.into())),
161            card: cipher_data
162                .create_request
163                .r#type
164                .as_card_view()
165                .as_ref()
166                .map(|c| c.encrypt_composite(ctx, cipher_key))
167                .transpose()?
168                .map(|c| Box::new(c.into())),
169            identity: cipher_data
170                .create_request
171                .r#type
172                .as_identity_view()
173                .as_ref()
174                .map(|i| i.encrypt_composite(ctx, cipher_key))
175                .transpose()?
176                .map(|i| Box::new(i.into())),
177            secure_note: cipher_data
178                .create_request
179                .r#type
180                .as_secure_note_view()
181                .as_ref()
182                .map(|s| s.encrypt_composite(ctx, cipher_key))
183                .transpose()?
184                .map(|s| Box::new(s.into())),
185            ssh_key: cipher_data
186                .create_request
187                .r#type
188                .as_ssh_key_view()
189                .as_ref()
190                .map(|s| s.encrypt_composite(ctx, cipher_key))
191                .transpose()?
192                .map(|s| Box::new(s.into())),
193            bank_account: cipher_data
194                .create_request
195                .r#type
196                .as_bank_account_view()
197                .as_ref()
198                .map(|b| b.encrypt_composite(ctx, cipher_key))
199                .transpose()?
200                .map(|b| Box::new(b.into())),
201            fields: Some(
202                cipher_data
203                    .create_request
204                    .fields
205                    .iter()
206                    .map(|f| f.encrypt_composite(ctx, cipher_key))
207                    .map(|f| f.map(|f| f.into()))
208                    .collect::<Result<Vec<_>, _>>()?,
209            ),
210            password_history: None,
211            attachments: None,
212            attachments2: None,
213            last_known_revision_date: None,
214            archived_date: None,
215            data: None,
216        };
217
218        Ok(cipher_request)
219    }
220}
221
222impl IdentifyKey<SymmetricKeySlotId> for CipherCreateRequestInternal {
223    fn key_identifier(&self) -> SymmetricKeySlotId {
224        match self.create_request.organization_id {
225            Some(organization_id) => SymmetricKeySlotId::Organization(organization_id),
226            None => SymmetricKeySlotId::User,
227        }
228    }
229}
230
231async fn create_cipher<R: Repository<Cipher> + ?Sized>(
232    key_store: &KeyStore<KeySlotIds>,
233    api_client: &bitwarden_api_api::apis::ApiClient,
234    repository: &R,
235    encrypted_for: UserId,
236    request: CipherCreateRequestInternal,
237) -> Result<CipherView, CreateCipherError> {
238    let collection_ids = request.create_request.collection_ids.clone();
239    let mut cipher_request = key_store.encrypt(request)?;
240    cipher_request.encrypted_for = Some(encrypted_for.into());
241
242    let mut cipher: Cipher;
243    if !collection_ids.is_empty() {
244        cipher = api_client
245            .ciphers_api()
246            .post_create(Some(CipherCreateRequestModel {
247                collection_ids: Some(collection_ids.iter().cloned().map(Into::into).collect()),
248                cipher: Box::new(cipher_request),
249            }))
250            .await
251            .map_err(ApiError::from)?
252            .merge_with_cipher(None)?;
253        cipher.collection_ids = collection_ids;
254        repository.set(require!(cipher.id), cipher.clone()).await?;
255    } else {
256        cipher = api_client
257            .ciphers_api()
258            .post(Some(cipher_request))
259            .await
260            .map_err(ApiError::from)?
261            .merge_with_cipher(None)?;
262        repository.set(require!(cipher.id), cipher.clone()).await?;
263    }
264
265    Ok(key_store.decrypt(&cipher)?)
266}
267
268#[cfg_attr(feature = "wasm", wasm_bindgen)]
269impl CiphersClient {
270    async fn create_cipher(
271        &self,
272        request: CipherCreateRequest,
273    ) -> Result<CipherView, CreateCipherError> {
274        let key_store = self.client.internal.get_key_store();
275        let config = self.client.internal.get_api_configurations();
276        let repository = self.get_repository()?;
277        let mut internal_request: CipherCreateRequestInternal = request.into();
278
279        let user_id = self
280            .client
281            .internal
282            .get_user_id()
283            .ok_or(NotAuthenticatedError)?;
284
285        // TODO: Once this flag is removed, the key generation logic should
286        // be moved closer to the actual encryption logic.
287        if self
288            .client
289            .internal
290            .get_flags()
291            .await
292            .enable_cipher_key_encryption
293        {
294            let key = internal_request.key_identifier();
295            internal_request.generate_cipher_key(&mut key_store.context(), key)?;
296        }
297
298        create_cipher(
299            key_store,
300            &config.api_client,
301            repository.as_ref(),
302            user_id,
303            internal_request,
304        )
305        .await
306    }
307
308    /// Creates a new [Cipher] and saves it to the server.
309    pub async fn create(
310        &self,
311        request: CipherCreateRequest,
312    ) -> Result<CipherView, CreateCipherError> {
313        self.create_cipher(request).await
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
320    use bitwarden_crypto::SymmetricKeyAlgorithm;
321    use bitwarden_test::MemoryRepository;
322    use chrono::Utc;
323
324    use super::*;
325    use crate::{CipherId, LoginView};
326
327    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
328    const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd";
329    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
330    const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
331
332    fn generate_test_cipher_create_request() -> CipherCreateRequest {
333        CipherCreateRequest {
334            name: "Test Login".to_string(),
335            notes: Some("Test notes".to_string()),
336            r#type: CipherViewType::Login(LoginView {
337                username: Some("[email protected]".to_string()),
338                password: Some("password123".to_string()),
339                password_revision_date: None,
340                uris: None,
341                totp: None,
342                autofill_on_page_load: None,
343                fido2_credentials: None,
344            }),
345            organization_id: Default::default(),
346            folder_id: Default::default(),
347            favorite: Default::default(),
348            reprompt: Default::default(),
349            fields: Default::default(),
350            collection_ids: vec![],
351        }
352    }
353
354    #[tokio::test]
355    async fn test_create_cipher() {
356        let store: KeyStore<KeySlotIds> = KeyStore::default();
357        {
358            let mut ctx = store.context_mut();
359            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
360            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
361                .unwrap();
362        }
363
364        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
365
366        let api_client = ApiClient::new_mocked(move |mock| {
367            mock.ciphers_api
368                .expect_post()
369                .returning(move |body| {
370                    let body = body.unwrap();
371                    Ok(CipherResponseModel {
372                        object: Some("cipher".to_string()),
373                        id: Some(cipher_id.into()),
374                        name: Some(body.name.clone()),
375                        r#type: body.r#type,
376                        organization_id: body
377                            .organization_id
378                            .as_ref()
379                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
380                        folder_id: body
381                            .folder_id
382                            .as_ref()
383                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
384                        favorite: body.favorite,
385                        reprompt: body.reprompt,
386                        key: body.key.clone(),
387                        notes: body.notes.clone(),
388                        view_password: Some(true),
389                        edit: Some(true),
390                        organization_use_totp: Some(true),
391                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
392                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
393                        deleted_date: None,
394                        login: body.login,
395                        card: body.card,
396                        identity: body.identity,
397                        secure_note: body.secure_note,
398                        ssh_key: body.ssh_key,
399                        bank_account: body.bank_account,
400                        fields: body.fields,
401                        password_history: body.password_history,
402                        attachments: None,
403                        permissions: None,
404                        data: None,
405                        archived_date: None,
406                    })
407                })
408                .once();
409        });
410
411        let repository = MemoryRepository::<Cipher>::default();
412        let request = generate_test_cipher_create_request();
413
414        let result = create_cipher(
415            &store,
416            &api_client,
417            &repository,
418            TEST_USER_ID.parse().unwrap(),
419            request.into(),
420        )
421        .await
422        .unwrap();
423
424        assert_eq!(result.id, Some(cipher_id));
425        assert_eq!(result.name, "Test Login");
426        assert_eq!(
427            result.login,
428            Some(LoginView {
429                username: Some("[email protected]".to_string()),
430                password: Some("password123".to_string()),
431                password_revision_date: None,
432                uris: None,
433                totp: None,
434                autofill_on_page_load: None,
435                fido2_credentials: None,
436            })
437        );
438
439        // Confirm the cipher was stored in the repository
440        let stored_cipher_view: CipherView = store
441            .decrypt(&repository.get(cipher_id).await.unwrap().unwrap())
442            .unwrap();
443        assert_eq!(stored_cipher_view.id, result.id);
444        assert_eq!(stored_cipher_view.name, result.name);
445        assert_eq!(stored_cipher_view.r#type, result.r#type);
446        assert!(stored_cipher_view.login.is_some());
447        assert_eq!(stored_cipher_view.favorite, result.favorite);
448    }
449
450    #[tokio::test]
451    async fn test_create_cipher_http_error() {
452        let store: KeyStore<KeySlotIds> = KeyStore::default();
453        {
454            let mut ctx = store.context_mut();
455            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
456            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
457                .unwrap();
458        }
459
460        let api_client = ApiClient::new_mocked(move |mock| {
461            mock.ciphers_api.expect_post().returning(move |_body| {
462                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
463                    "Simulated error",
464                )))
465            });
466        });
467
468        let repository = MemoryRepository::<Cipher>::default();
469
470        let request = generate_test_cipher_create_request();
471
472        let result = create_cipher(
473            &store,
474            &api_client,
475            &repository,
476            TEST_USER_ID.parse().unwrap(),
477            request.into(),
478        )
479        .await;
480
481        assert!(result.is_err());
482        assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_)));
483    }
484
485    #[tokio::test]
486    async fn test_create_org_cipher() {
487        let api_client = ApiClient::new_mocked(move |mock| {
488            mock.ciphers_api
489                .expect_post_create()
490                .returning(move |body| {
491                    let request_body = body.unwrap();
492
493                    Ok(CipherResponseModel {
494                        id: Some(TEST_CIPHER_ID.try_into().unwrap()),
495                        organization_id: request_body
496                            .cipher
497                            .organization_id
498                            .and_then(|id| id.parse().ok()),
499                        name: Some(request_body.cipher.name.clone()),
500                        r#type: request_body.cipher.r#type,
501                        creation_date: Some(Utc::now().to_string()),
502                        revision_date: Some(Utc::now().to_string()),
503                        ..Default::default()
504                    })
505                })
506                .once();
507        });
508
509        let store: KeyStore<KeySlotIds> = KeyStore::default();
510        {
511            let mut ctx = store.context_mut();
512            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
513            ctx.persist_symmetric_key(
514                local_key_id,
515                SymmetricKeySlotId::Organization(TEST_ORG_ID.parse().unwrap()),
516            )
517            .unwrap();
518        }
519        let repository = MemoryRepository::<Cipher>::default();
520        let request = CipherCreateRequest {
521            organization_id: Some(TEST_ORG_ID.parse().unwrap()),
522            collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()],
523            folder_id: None,
524            name: "Test Cipher".into(),
525            notes: None,
526            favorite: false,
527            reprompt: CipherRepromptType::None,
528            r#type: CipherViewType::Login(LoginView {
529                username: None,
530                password: None,
531                password_revision_date: None,
532                uris: None,
533                totp: None,
534                autofill_on_page_load: None,
535                fido2_credentials: None,
536            }),
537            fields: vec![],
538        };
539
540        let response = create_cipher(
541            &store,
542            &api_client,
543            &repository,
544            TEST_USER_ID.parse().unwrap(),
545            request.into(),
546        )
547        .await
548        .unwrap();
549
550        let cipher: Cipher = repository
551            .get(TEST_CIPHER_ID.parse().unwrap())
552            .await
553            .unwrap()
554            .unwrap();
555        let cipher_view: CipherView = store.decrypt(&cipher).unwrap();
556
557        assert_eq!(response.id, cipher_view.id);
558        assert_eq!(response.organization_id, cipher_view.organization_id);
559
560        assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap()));
561        assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap()));
562    }
563}