1use bitwarden_api_api::models::CipherRequestModel;
2use bitwarden_core::{
3 ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
4 key_management::{KeyIds, SymmetricKeyId},
5 require,
6};
7use bitwarden_crypto::{
8 CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext,
9 PrimitiveEncryptable,
10};
11use bitwarden_error::bitwarden_error;
12use bitwarden_state::repository::{Repository, RepositoryError};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20use super::CiphersClient;
21use crate::{
22 Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError,
23 cipher_view_type::CipherViewType,
24};
25
26#[allow(missing_docs)]
27#[bitwarden_error(flat)]
28#[derive(Debug, Error)]
29pub enum CreateCipherError {
30 #[error(transparent)]
31 Crypto(#[from] CryptoError),
32 #[error(transparent)]
33 Api(#[from] ApiError),
34 #[error(transparent)]
35 VaultParse(#[from] VaultParseError),
36 #[error(transparent)]
37 MissingField(#[from] MissingFieldError),
38 #[error(transparent)]
39 NotAuthenticated(#[from] NotAuthenticatedError),
40 #[error(transparent)]
41 Repository(#[from] RepositoryError),
42}
43
44#[derive(Serialize, Deserialize, Clone, Debug)]
46#[serde(rename_all = "camelCase")]
47#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
48#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
49pub struct CipherCreateRequest {
50 pub organization_id: Option<OrganizationId>,
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}
59
60#[derive(Clone, Debug)]
63struct CipherCreateRequestInternal {
64 create_request: CipherCreateRequest,
65 key: Option<EncString>,
66}
67
68impl From<CipherCreateRequest> for CipherCreateRequestInternal {
69 fn from(create_request: CipherCreateRequest) -> Self {
70 Self {
71 create_request,
72 key: None,
73 }
74 }
75}
76
77impl CipherCreateRequestInternal {
78 fn generate_cipher_key(
81 &mut self,
82 ctx: &mut KeyStoreContext<KeyIds>,
83 key: SymmetricKeyId,
84 ) -> Result<(), CryptoError> {
85 let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
86
87 let new_key = ctx.generate_symmetric_key();
88 self.create_request
89 .r#type
90 .as_login_view_mut()
91 .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key))
92 .transpose()?;
93
94 self.key = Some(ctx.wrap_symmetric_key(key, new_key)?);
95 Ok(())
96 }
97
98 fn generate_checksums(&mut self) {
99 if let Some(login) = &mut self.create_request.r#type.as_login_view_mut() {
100 login.generate_checksums();
101 }
102 }
103}
104
105impl CompositeEncryptable<KeyIds, SymmetricKeyId, CipherRequestModel>
106 for CipherCreateRequestInternal
107{
108 fn encrypt_composite(
109 &self,
110 ctx: &mut KeyStoreContext<KeyIds>,
111 key: SymmetricKeyId,
112 ) -> Result<CipherRequestModel, CryptoError> {
113 let mut cipher_data = (*self).clone();
115 cipher_data.generate_checksums();
116
117 let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &cipher_data.key)?;
118
119 let cipher_request = CipherRequestModel {
120 encrypted_for: None,
121 r#type: Some(cipher_data.create_request.r#type.get_cipher_type().into()),
122 organization_id: cipher_data
123 .create_request
124 .organization_id
125 .map(|id| id.to_string()),
126 folder_id: cipher_data
127 .create_request
128 .folder_id
129 .map(|id| id.to_string()),
130 favorite: Some(cipher_data.create_request.favorite),
131 reprompt: Some(cipher_data.create_request.reprompt.into()),
132 key: cipher_data.key.map(|k| k.to_string()),
133 name: cipher_data
134 .create_request
135 .name
136 .encrypt(ctx, cipher_key)?
137 .to_string(),
138 notes: cipher_data
139 .create_request
140 .notes
141 .as_ref()
142 .map(|n| n.encrypt(ctx, cipher_key))
143 .transpose()?
144 .map(|n| n.to_string()),
145 login: cipher_data
146 .create_request
147 .r#type
148 .as_login_view()
149 .as_ref()
150 .map(|l| l.encrypt_composite(ctx, cipher_key))
151 .transpose()?
152 .map(|l| Box::new(l.into())),
153 card: cipher_data
154 .create_request
155 .r#type
156 .as_card_view()
157 .as_ref()
158 .map(|c| c.encrypt_composite(ctx, cipher_key))
159 .transpose()?
160 .map(|c| Box::new(c.into())),
161 identity: cipher_data
162 .create_request
163 .r#type
164 .as_identity_view()
165 .as_ref()
166 .map(|i| i.encrypt_composite(ctx, cipher_key))
167 .transpose()?
168 .map(|i| Box::new(i.into())),
169 secure_note: cipher_data
170 .create_request
171 .r#type
172 .as_secure_note_view()
173 .as_ref()
174 .map(|s| s.encrypt_composite(ctx, cipher_key))
175 .transpose()?
176 .map(|s| Box::new(s.into())),
177 ssh_key: cipher_data
178 .create_request
179 .r#type
180 .as_ssh_key_view()
181 .as_ref()
182 .map(|s| s.encrypt_composite(ctx, cipher_key))
183 .transpose()?
184 .map(|s| Box::new(s.into())),
185 fields: Some(
186 cipher_data
187 .create_request
188 .fields
189 .iter()
190 .map(|f| f.encrypt_composite(ctx, cipher_key))
191 .map(|f| f.map(|f| f.into()))
192 .collect::<Result<Vec<_>, _>>()?,
193 ),
194 password_history: None,
195 attachments: None,
196 attachments2: None,
197 last_known_revision_date: None,
198 archived_date: None,
199 data: None,
200 };
201
202 Ok(cipher_request)
203 }
204}
205
206impl IdentifyKey<SymmetricKeyId> for CipherCreateRequestInternal {
207 fn key_identifier(&self) -> SymmetricKeyId {
208 match self.create_request.organization_id {
209 Some(organization_id) => SymmetricKeyId::Organization(organization_id),
210 None => SymmetricKeyId::User,
211 }
212 }
213}
214
215async fn create_cipher<R: Repository<Cipher> + ?Sized>(
216 key_store: &KeyStore<KeyIds>,
217 api_client: &bitwarden_api_api::apis::ApiClient,
218 repository: &R,
219 encrypted_for: UserId,
220 request: CipherCreateRequestInternal,
221) -> Result<CipherView, CreateCipherError> {
222 let mut cipher_request = key_store.encrypt(request)?;
223 cipher_request.encrypted_for = Some(encrypted_for.into());
224
225 let resp = api_client
226 .ciphers_api()
227 .post(Some(cipher_request))
228 .await
229 .map_err(ApiError::from)?;
230 let cipher: Cipher = resp.try_into()?;
231 repository
232 .set(require!(cipher.id).to_string(), cipher.clone())
233 .await?;
234 Ok(key_store.decrypt(&cipher)?)
235}
236
237#[cfg_attr(feature = "wasm", wasm_bindgen)]
238impl CiphersClient {
239 pub async fn create(
241 &self,
242 request: CipherCreateRequest,
243 ) -> Result<CipherView, CreateCipherError> {
244 let key_store = self.client.internal.get_key_store();
245 let config = self.client.internal.get_api_configurations().await;
246 let repository = self.get_repository()?;
247 let mut internal_request: CipherCreateRequestInternal = request.into();
248
249 let user_id = self
250 .client
251 .internal
252 .get_user_id()
253 .ok_or(NotAuthenticatedError)?;
254
255 if self
258 .client
259 .internal
260 .get_flags()
261 .enable_cipher_key_encryption
262 {
263 let key = internal_request.key_identifier();
264 internal_request.generate_cipher_key(&mut key_store.context(), key)?;
265 }
266
267 create_cipher(
268 key_store,
269 &config.api_client,
270 repository.as_ref(),
271 user_id,
272 internal_request,
273 )
274 .await
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
281 use bitwarden_crypto::SymmetricCryptoKey;
282 use bitwarden_test::MemoryRepository;
283
284 use super::*;
285 use crate::{CipherId, LoginView};
286
287 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
288 const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
289
290 fn generate_test_cipher_create_request() -> CipherCreateRequest {
291 CipherCreateRequest {
292 name: "Test Login".to_string(),
293 notes: Some("Test notes".to_string()),
294 r#type: CipherViewType::Login(LoginView {
295 username: Some("[email protected]".to_string()),
296 password: Some("password123".to_string()),
297 password_revision_date: None,
298 uris: None,
299 totp: None,
300 autofill_on_page_load: None,
301 fido2_credentials: None,
302 }),
303 organization_id: Default::default(),
304 folder_id: Default::default(),
305 favorite: Default::default(),
306 reprompt: Default::default(),
307 fields: Default::default(),
308 }
309 }
310
311 #[tokio::test]
312 async fn test_create_cipher() {
313 let store: KeyStore<KeyIds> = KeyStore::default();
314 #[allow(deprecated)]
315 let _ = store.context_mut().set_symmetric_key(
316 SymmetricKeyId::User,
317 SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
318 );
319
320 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
321
322 let api_client = ApiClient::new_mocked(move |mock| {
323 mock.ciphers_api
324 .expect_post()
325 .returning(move |body| {
326 let body = body.unwrap();
327 Ok(CipherResponseModel {
328 object: Some("cipher".to_string()),
329 id: Some(cipher_id.into()),
330 name: Some(body.name.clone()),
331 r#type: body.r#type,
332 organization_id: body
333 .organization_id
334 .as_ref()
335 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
336 folder_id: body
337 .folder_id
338 .as_ref()
339 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
340 favorite: body.favorite,
341 reprompt: body.reprompt,
342 key: body.key.clone(),
343 notes: body.notes.clone(),
344 view_password: Some(true),
345 edit: Some(true),
346 organization_use_totp: Some(true),
347 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
348 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
349 deleted_date: None,
350 login: body.login,
351 card: body.card,
352 identity: body.identity,
353 secure_note: body.secure_note,
354 ssh_key: body.ssh_key,
355 fields: body.fields,
356 password_history: body.password_history,
357 attachments: None,
358 permissions: None,
359 data: None,
360 archived_date: None,
361 })
362 })
363 .once();
364 });
365
366 let repository = MemoryRepository::<Cipher>::default();
367 let request = generate_test_cipher_create_request();
368
369 let result = create_cipher(
370 &store,
371 &api_client,
372 &repository,
373 TEST_USER_ID.parse().unwrap(),
374 request.into(),
375 )
376 .await
377 .unwrap();
378
379 assert_eq!(result.id, Some(cipher_id));
380 assert_eq!(result.name, "Test Login");
381 assert_eq!(
382 result.login,
383 Some(LoginView {
384 username: Some("[email protected]".to_string()),
385 password: Some("password123".to_string()),
386 password_revision_date: None,
387 uris: None,
388 totp: None,
389 autofill_on_page_load: None,
390 fido2_credentials: None,
391 })
392 );
393
394 let stored_cipher_view: CipherView = store
396 .decrypt(
397 &repository
398 .get(cipher_id.to_string())
399 .await
400 .unwrap()
401 .unwrap(),
402 )
403 .unwrap();
404 assert_eq!(stored_cipher_view.id, result.id);
405 assert_eq!(stored_cipher_view.name, result.name);
406 assert_eq!(stored_cipher_view.r#type, result.r#type);
407 assert!(stored_cipher_view.login.is_some());
408 assert_eq!(stored_cipher_view.favorite, result.favorite);
409 }
410
411 #[tokio::test]
412 async fn test_create_cipher_http_error() {
413 let store: KeyStore<KeyIds> = KeyStore::default();
414 #[allow(deprecated)]
415 let _ = store.context_mut().set_symmetric_key(
416 SymmetricKeyId::User,
417 SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
418 );
419
420 let api_client = ApiClient::new_mocked(move |mock| {
421 mock.ciphers_api.expect_post().returning(move |_body| {
422 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
423 "Simulated error",
424 )))
425 });
426 });
427
428 let repository = MemoryRepository::<Cipher>::default();
429
430 let request = generate_test_cipher_create_request();
431
432 let result = create_cipher(
433 &store,
434 &api_client,
435 &repository,
436 TEST_USER_ID.parse().unwrap(),
437 request.into(),
438 )
439 .await;
440
441 assert!(result.is_err());
442 assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_)));
443 }
444}