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#[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#[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 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 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 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 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 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}