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
14use super::CiphersClient;
15use crate::{
16 Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
17 cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
18 cipher_view_type::CipherViewType,
19};
20
21#[allow(missing_docs)]
22#[bitwarden_error(flat)]
23#[derive(Debug, Error)]
24pub enum CreateCipherError {
25 #[error(transparent)]
26 Crypto(#[from] CryptoError),
27 #[error(transparent)]
28 Api(#[from] ApiError),
29 #[error(transparent)]
30 VaultParse(#[from] VaultParseError),
31 #[error(transparent)]
32 MissingField(#[from] MissingFieldError),
33 #[error(transparent)]
34 NotAuthenticated(#[from] NotAuthenticatedError),
35 #[error(transparent)]
36 Repository(#[from] RepositoryError),
37}
38
39#[derive(Serialize, Deserialize, Clone, Debug)]
41#[serde(rename_all = "camelCase")]
42#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
43#[bitwarden_ffi::wasm_record]
44pub struct CipherCreateRequest {
45 pub organization_id: Option<OrganizationId>,
47 pub collection_ids: Vec<CollectionId>,
49 pub folder_id: Option<FolderId>,
51 pub name: String,
53 pub notes: Option<String>,
55 pub favorite: bool,
57 pub reprompt: CipherRepromptType,
59 pub r#type: CipherViewType,
61 pub fields: Vec<FieldView>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub archived_date: Option<DateTime<Utc>>,
66}
67
68pub(crate) fn convert_request_to_cipher_view(r: CipherCreateRequest) -> CipherView {
75 let now = chrono::Utc::now();
78 CipherView {
79 partial: false,
80 id: None,
81 organization_id: r.organization_id,
82 folder_id: r.folder_id,
83 collection_ids: r.collection_ids,
84 key: None,
85 name: r.name,
86 notes: r.notes,
87 r#type: r.r#type.get_cipher_type(),
88 login: r.r#type.as_login_view().cloned(),
89 identity: r.r#type.as_identity_view().cloned(),
90 card: r.r#type.as_card_view().cloned(),
91 secure_note: r.r#type.as_secure_note_view().cloned(),
92 ssh_key: r.r#type.as_ssh_key_view().cloned(),
93 bank_account: r.r#type.as_bank_account_view().cloned(),
94 drivers_license: r.r#type.as_drivers_license_view().cloned(),
95 passport: r.r#type.as_passport_view().cloned(),
96 favorite: r.favorite,
97 reprompt: r.reprompt,
98 organization_use_totp: false,
99 edit: true,
100 permissions: None,
101 view_password: true,
102 local_data: None,
103 attachments: None,
104 attachment_decryption_failures: None,
105 fields: Some(r.fields),
106 password_history: None,
107 creation_date: now,
108 deleted_date: None,
109 revision_date: now,
110 archived_date: r.archived_date,
111 }
112}
113
114async fn create_cipher<R: Repository<Cipher> + ?Sized>(
115 key_store: &KeyStore<KeySlotIds>,
116 api_client: &bitwarden_api_api::apis::ApiClient,
117 repository: &R,
118 encrypted_for: UserId,
119 view: CipherView,
120 use_strict_decryption: bool,
121 use_blob: bool,
122) -> Result<CipherView, CreateCipherError> {
123 let collection_ids = view.collection_ids.clone();
124 let encrypted_by_key_id = key_store
125 .context()
126 .get_symmetric_key_id(view.key_identifier())
127 .map(|id| id.to_string());
128 let mode = if use_blob {
129 EncryptMode::Blob(view)
130 } else {
131 EncryptMode::Legacy(view)
132 };
133 let cipher: Cipher = key_store.encrypt(mode)?;
134 let mut cipher_request: CipherRequestModel = cipher.try_into()?;
135 cipher_request.encrypted_for = Some(encrypted_for.into());
136 cipher_request.encrypted_by_key_id = encrypted_by_key_id;
137
138 let mut cipher: Cipher;
139 if !collection_ids.is_empty() {
140 cipher = api_client
141 .ciphers_api()
142 .post_create(Some(CipherCreateRequestModel {
143 collection_ids: Some(collection_ids.iter().cloned().map(Into::into).collect()),
144 cipher: Box::new(cipher_request),
145 }))
146 .await?
147 .merge_with_cipher(None)?;
148 cipher.collection_ids = collection_ids;
149 repository.set(require!(cipher.id), cipher.clone()).await?;
150 } else {
151 cipher = api_client
152 .ciphers_api()
153 .post(Some(cipher_request))
154 .await?
155 .merge_with_cipher(None)?;
156 repository.set(require!(cipher.id), cipher.clone()).await?;
157 }
158
159 Ok(if use_strict_decryption {
160 key_store.decrypt(&StrictDecrypt(cipher))?
161 } else {
162 key_store.decrypt(&cipher)?
163 })
164}
165
166#[allow(deprecated)]
167#[bitwarden_ffi::wasm_export]
168impl CiphersClient {
169 pub async fn create(
171 &self,
172 request: CipherCreateRequest,
173 ) -> Result<CipherView, CreateCipherError> {
174 let key_store = self.client.internal.get_key_store();
175 let config = self.client.internal.get_api_configurations();
176 let repository = self.get_repository()?;
177
178 let user_id = self
179 .client
180 .internal
181 .get_user_id()
182 .ok_or(NotAuthenticatedError)?;
183
184 let view: CipherView = convert_request_to_cipher_view(request);
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: 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 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: request_body.cipher.name.clone(),
391 r#type: request_body.cipher.r#type,
392 key: request_body.cipher.key.clone(),
393 creation_date: Some(Utc::now().to_string()),
394 revision_date: Some(Utc::now().to_string()),
395 ..Default::default()
396 })
397 })
398 .once();
399 });
400
401 let store: KeyStore<KeySlotIds> = KeyStore::default();
402 {
403 let mut ctx = store.context_mut();
404 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
405 ctx.persist_symmetric_key(
406 local_key_id,
407 SymmetricKeySlotId::Organization(TEST_ORG_ID.parse().unwrap()),
408 )
409 .unwrap();
410 }
411 let repository = MemoryRepository::<Cipher>::default();
412 let request = CipherCreateRequest {
413 organization_id: Some(TEST_ORG_ID.parse().unwrap()),
414 collection_ids: vec![TEST_COLLECTION_ID.parse().unwrap()],
415 folder_id: None,
416 name: "Test Cipher".into(),
417 notes: None,
418 favorite: false,
419 reprompt: CipherRepromptType::None,
420 r#type: CipherViewType::Login(LoginView {
421 username: None,
422 password: None,
423 password_revision_date: None,
424 uris: None,
425 totp: None,
426 autofill_on_page_load: None,
427 fido2_credentials: None,
428 }),
429 fields: vec![],
430 archived_date: None,
431 };
432
433 let response = create_cipher(
434 &store,
435 &api_client,
436 &repository,
437 TEST_USER_ID.parse().unwrap(),
438 convert_request_to_cipher_view(request),
439 false,
440 false,
441 )
442 .await
443 .unwrap();
444
445 let cipher: Cipher = repository
446 .get(TEST_CIPHER_ID.parse().unwrap())
447 .await
448 .unwrap()
449 .unwrap();
450 let cipher_view: CipherView = store.decrypt(&cipher).unwrap();
451
452 assert_eq!(response.id, cipher_view.id);
453 assert_eq!(response.organization_id, cipher_view.organization_id);
454 assert_eq!(cipher_view.name, "Test Cipher");
455
456 assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap()));
457 assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap()));
458 }
459}