1use bitwarden_api_api::models::{
2 SendFileModel, SendResponseModel, SendTextModel, SendWithIdRequestModel,
3};
4use bitwarden_core::{
5 key_management::{KeySlotIds, SymmetricKeySlotId},
6 require,
7};
8use bitwarden_crypto::{
9 CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
10 OctetStreamBytes, PrimitiveEncryptable, generate_random_bytes,
11};
12use bitwarden_encoding::{B64, B64Url};
13use bitwarden_uuid::uuid_newtype;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use serde_repr::{Deserialize_repr, Serialize_repr};
17use thiserror::Error;
18use zeroize::Zeroizing;
19#[cfg(feature = "wasm")]
20use {tsify::Tsify, wasm_bindgen::prelude::*};
21
22use crate::SendParseError;
23pub const SEND_ITERATIONS: u32 = 100_000;
24
25uuid_newtype!(pub SendId);
26
27#[derive(Debug, Error)]
29#[error("Email authentication requires at least one email address")]
30pub struct EmptyEmailListError;
31
32#[derive(Serialize, Deserialize, Debug, Clone)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
36#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
37pub struct SendFile {
38 pub id: Option<String>,
40 pub file_name: EncString,
42 pub size: Option<String>,
44 pub size_name: Option<String>,
46}
47
48#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
52#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
53pub struct SendFileView {
54 pub id: Option<String>,
56 pub file_name: String,
58 pub size: Option<String>,
60 pub size_name: Option<String>,
62}
63
64#[derive(Serialize, Deserialize, Debug, Clone)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
68#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
69pub struct SendText {
70 pub text: Option<EncString>,
71 pub hidden: bool,
72}
73
74#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
78#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
79pub struct SendTextView {
80 pub text: Option<String>,
82 pub hidden: bool,
84}
85
86#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
88#[repr(u8)]
89#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
90#[cfg_attr(feature = "wasm", wasm_bindgen)]
91pub enum SendType {
92 Text = 0,
94 File = 1,
96 Item = 2,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
102#[repr(u8)]
103#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
104#[cfg_attr(feature = "wasm", wasm_bindgen)]
105pub enum AuthType {
106 Email = 0,
108
109 Password = 1,
111
112 None = 2,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "type", rename_all = "camelCase")]
120#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
121#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
122pub enum SendAuthType {
123 None,
125 Password {
128 password: String,
130 },
131 HashedPassword {
137 #[serde(rename = "keyB64")]
139 key_b64: String,
140 },
141 Emails {
143 emails: Vec<String>,
145 },
146}
147
148impl SendAuthType {
149 pub fn from_plaintext_password(password: String) -> Self {
152 SendAuthType::Password { password }
153 }
154
155 pub fn from_hashed_password(key_b64: String) -> Self {
159 SendAuthType::HashedPassword { key_b64 }
160 }
161
162 pub fn auth_type(&self) -> AuthType {
164 match self {
165 SendAuthType::None => AuthType::None,
166 SendAuthType::Password { .. } | SendAuthType::HashedPassword { .. } => {
167 AuthType::Password
168 }
169 SendAuthType::Emails { .. } => AuthType::Email,
170 }
171 }
172
173 pub(crate) fn validate(&self) -> Result<(), EmptyEmailListError> {
176 if let SendAuthType::Emails { emails } = self
177 && emails.is_empty()
178 {
179 return Err(EmptyEmailListError);
180 }
181 Ok(())
182 }
183
184 pub(crate) fn auth_data(&self, k: &[u8]) -> (Option<String>, Option<String>) {
188 match self {
189 SendAuthType::Password { password } => {
190 let hashed = bitwarden_crypto::pbkdf2(password.as_bytes(), k, SEND_ITERATIONS);
191 (Some(B64::from(hashed.as_slice()).to_string()), None)
192 }
193 SendAuthType::HashedPassword { key_b64 } => (Some(key_b64.clone()), None),
194 SendAuthType::Emails { emails } => {
195 let emails_str = if emails.is_empty() {
196 None
197 } else {
198 Some(emails.join(","))
199 };
200 (None, emails_str)
201 }
202 SendAuthType::None => (None, None),
203 }
204 }
205}
206
207#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
209#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
210pub enum SendViewType {
211 File(SendFileView),
213 Text(SendTextView),
215}
216
217type SendApiModels = (
219 bitwarden_api_api::models::SendType,
220 Option<Box<bitwarden_api_api::models::SendFileModel>>,
221 Option<Box<bitwarden_api_api::models::SendTextModel>>,
222);
223
224impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendApiModels> for SendViewType {
225 fn encrypt_composite(
226 &self,
227 ctx: &mut KeyStoreContext<KeySlotIds>,
228 key: SymmetricKeySlotId,
229 ) -> Result<SendApiModels, CryptoError> {
230 match self {
231 SendViewType::File(f) => Ok((
232 bitwarden_api_api::models::SendType::File,
233 Some(Box::new(bitwarden_api_api::models::SendFileModel {
234 id: f.id.clone(),
235 file_name: Some(f.file_name.encrypt(ctx, key)?.to_string()),
236 size: f.size.clone(),
237 size_name: f.size_name.clone(),
238 })),
239 None,
240 )),
241 SendViewType::Text(t) => Ok((
242 bitwarden_api_api::models::SendType::Text,
243 None,
244 Some(Box::new(bitwarden_api_api::models::SendTextModel {
245 text: t
246 .text
247 .as_ref()
248 .map(|txt| txt.encrypt(ctx, key))
249 .transpose()?
250 .map(|e| e.to_string()),
251 hidden: Some(t.hidden),
252 })),
253 )),
254 }
255 }
256}
257
258#[allow(missing_docs)]
259#[derive(Serialize, Deserialize, Debug, Clone)]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
262#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
263pub struct Send {
264 pub id: Option<SendId>,
265 pub access_id: Option<String>,
266
267 pub name: EncString,
268 pub notes: Option<EncString>,
269 pub key: EncString,
270 pub password: Option<String>,
271
272 pub r#type: SendType,
273 pub file: Option<SendFile>,
274 pub text: Option<SendText>,
275
276 pub max_access_count: Option<u32>,
277 pub access_count: u32,
278 pub disabled: bool,
279 pub hide_email: bool,
280
281 pub revision_date: DateTime<Utc>,
282 pub deletion_date: DateTime<Utc>,
283 pub expiration_date: Option<DateTime<Utc>>,
284
285 pub emails: Option<String>,
290 pub auth_type: AuthType,
291}
292
293bitwarden_state::register_repository_item!(SendId => Send, "Send");
294
295impl From<Send> for SendWithIdRequestModel {
296 fn from(send: Send) -> Self {
297 let file_length = send.file.as_ref().and_then(|file| {
298 file.size
299 .as_deref()
300 .and_then(|size| size.parse::<i64>().ok())
301 });
302
303 SendWithIdRequestModel {
304 r#type: Some(send.r#type.into()),
305 auth_type: Some(send.auth_type.into()),
306 file_length,
307 name: Some(send.name.to_string()),
308 notes: send.notes.map(|notes| notes.to_string()),
309 key: send.key.to_string(),
310 max_access_count: send.max_access_count.map(|count| count as i32),
311 expiration_date: send.expiration_date.map(|date| date.to_rfc3339()),
312 deletion_date: send.deletion_date.to_rfc3339(),
313 file: send.file.map(|file| Box::new(file.into())),
314 text: send.text.map(|text| Box::new(text.into())),
315 data: None,
317 password: send.password,
318 emails: send.emails,
319 disabled: send.disabled,
320 hide_email: Some(send.hide_email),
321 id: send
322 .id
323 .expect("SendWithIdRequestModel conversion requires send id")
324 .into(),
325 }
326 }
327}
328
329#[allow(missing_docs)]
330#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
331#[serde(rename_all = "camelCase", deny_unknown_fields)]
332#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
333#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
334pub struct SendView {
335 pub id: Option<SendId>,
336 pub access_id: Option<String>,
337
338 pub name: String,
339 pub notes: Option<String>,
340 pub key: Option<String>,
342 pub new_password: Option<String>,
346 pub has_password: bool,
349
350 pub r#type: SendType,
351 pub file: Option<SendFileView>,
352 pub text: Option<SendTextView>,
353
354 pub max_access_count: Option<u32>,
355 pub access_count: u32,
356 pub disabled: bool,
357 pub hide_email: bool,
358
359 pub revision_date: DateTime<Utc>,
360 pub deletion_date: DateTime<Utc>,
361 pub expiration_date: Option<DateTime<Utc>>,
362
363 pub emails: Vec<String>,
368 pub auth_type: AuthType,
369}
370
371#[allow(missing_docs)]
372#[derive(Serialize, Deserialize, Debug)]
373#[serde(rename_all = "camelCase", deny_unknown_fields)]
374#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
375#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
376pub struct SendListView {
377 pub id: Option<SendId>,
378 pub access_id: Option<String>,
379
380 pub name: String,
381
382 pub r#type: SendType,
383 pub disabled: bool,
384
385 pub revision_date: DateTime<Utc>,
386 pub deletion_date: DateTime<Utc>,
387 pub expiration_date: Option<DateTime<Utc>>,
388
389 pub auth_type: AuthType,
390}
391
392impl Send {
393 #[allow(missing_docs)]
394 pub fn get_key(
395 ctx: &mut KeyStoreContext<KeySlotIds>,
396 send_key: &EncString,
397 enc_key: SymmetricKeySlotId,
398 ) -> Result<SymmetricKeySlotId, CryptoError> {
399 let key: Vec<u8> = send_key.decrypt(ctx, enc_key)?;
400 Self::derive_shareable_key(ctx, &key)
401 }
402
403 pub(crate) fn derive_shareable_key(
404 ctx: &mut KeyStoreContext<KeySlotIds>,
405 key: &[u8],
406 ) -> Result<SymmetricKeySlotId, CryptoError> {
407 let key = Zeroizing::new(key.try_into().map_err(|_| CryptoError::InvalidKeyLen)?);
408 ctx.derive_shareable_key(key, "send", Some("send"))
409 }
410}
411
412impl IdentifyKey<SymmetricKeySlotId> for Send {
413 fn key_identifier(&self) -> SymmetricKeySlotId {
414 SymmetricKeySlotId::User
415 }
416}
417
418impl IdentifyKey<SymmetricKeySlotId> for SendView {
419 fn key_identifier(&self) -> SymmetricKeySlotId {
420 SymmetricKeySlotId::User
421 }
422}
423
424impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendTextView> for SendText {
425 fn decrypt(
426 &self,
427 ctx: &mut KeyStoreContext<KeySlotIds>,
428 key: SymmetricKeySlotId,
429 ) -> Result<SendTextView, CryptoError> {
430 Ok(SendTextView {
431 text: self.text.decrypt(ctx, key)?,
432 hidden: self.hidden,
433 })
434 }
435}
436
437impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendText> for SendTextView {
438 fn encrypt_composite(
439 &self,
440 ctx: &mut KeyStoreContext<KeySlotIds>,
441 key: SymmetricKeySlotId,
442 ) -> Result<SendText, CryptoError> {
443 Ok(SendText {
444 text: self.text.encrypt(ctx, key)?,
445 hidden: self.hidden,
446 })
447 }
448}
449
450impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendFileView> for SendFile {
451 fn decrypt(
452 &self,
453 ctx: &mut KeyStoreContext<KeySlotIds>,
454 key: SymmetricKeySlotId,
455 ) -> Result<SendFileView, CryptoError> {
456 Ok(SendFileView {
457 id: self.id.clone(),
458 file_name: self.file_name.decrypt(ctx, key)?,
459 size: self.size.clone(),
460 size_name: self.size_name.clone(),
461 })
462 }
463}
464
465impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendFile> for SendFileView {
466 fn encrypt_composite(
467 &self,
468 ctx: &mut KeyStoreContext<KeySlotIds>,
469 key: SymmetricKeySlotId,
470 ) -> Result<SendFile, CryptoError> {
471 Ok(SendFile {
472 id: self.id.clone(),
473 file_name: self.file_name.encrypt(ctx, key)?,
474 size: self.size.clone(),
475 size_name: self.size_name.clone(),
476 })
477 }
478}
479
480impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendView> for Send {
481 fn decrypt(
482 &self,
483 ctx: &mut KeyStoreContext<KeySlotIds>,
484 key: SymmetricKeySlotId,
485 ) -> Result<SendView, CryptoError> {
486 let k: Vec<u8> = self.key.decrypt(ctx, key)?;
490 let key = Send::derive_shareable_key(ctx, &k)?;
491
492 Ok(SendView {
493 id: self.id,
494 access_id: self.access_id.clone(),
495
496 name: self.name.decrypt(ctx, key).ok().unwrap_or_default(),
497 notes: self.notes.decrypt(ctx, key).ok().flatten(),
498 key: Some(B64Url::from(k).to_string()),
499 new_password: None,
500 has_password: self.password.is_some(),
501
502 r#type: self.r#type,
503 file: self.file.decrypt(ctx, key).ok().flatten(),
504 text: self.text.decrypt(ctx, key).ok().flatten(),
505
506 max_access_count: self.max_access_count,
507 access_count: self.access_count,
508 disabled: self.disabled,
509 hide_email: self.hide_email,
510
511 revision_date: self.revision_date,
512 deletion_date: self.deletion_date,
513 expiration_date: self.expiration_date,
514
515 emails: self
516 .emails
517 .as_deref()
518 .unwrap_or_default()
519 .split(',')
520 .map(|e| e.trim())
521 .filter(|e| !e.is_empty())
522 .map(String::from)
523 .collect(),
524 auth_type: self.auth_type,
525 })
526 }
527}
528
529impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendListView> for Send {
530 fn decrypt(
531 &self,
532 ctx: &mut KeyStoreContext<KeySlotIds>,
533 key: SymmetricKeySlotId,
534 ) -> Result<SendListView, CryptoError> {
535 let key = Send::get_key(ctx, &self.key, key)?;
539
540 Ok(SendListView {
541 id: self.id,
542 access_id: self.access_id.clone(),
543
544 name: self.name.decrypt(ctx, key)?,
545 r#type: self.r#type,
546
547 disabled: self.disabled,
548
549 revision_date: self.revision_date,
550 deletion_date: self.deletion_date,
551 expiration_date: self.expiration_date,
552
553 auth_type: self.auth_type,
554 })
555 }
556}
557
558impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Send> for SendView {
559 fn encrypt_composite(
560 &self,
561 ctx: &mut KeyStoreContext<KeySlotIds>,
562 key: SymmetricKeySlotId,
563 ) -> Result<Send, CryptoError> {
564 let k = match (&self.key, &self.id) {
568 (Some(k), _) => B64Url::try_from(k.as_str())
570 .map_err(|_| CryptoError::InvalidKey)?
571 .as_bytes()
572 .to_vec(),
573 (None, None) => {
575 let key = generate_random_bytes::<[u8; 16]>();
576 key.to_vec()
577 }
578 _ => return Err(CryptoError::InvalidKey),
580 };
581 let send_key = Send::derive_shareable_key(ctx, &k)?;
582
583 Ok(Send {
584 id: self.id,
585 access_id: self.access_id.clone(),
586
587 name: self.name.encrypt(ctx, send_key)?,
588 notes: self.notes.encrypt(ctx, send_key)?,
589 key: OctetStreamBytes::from(k.clone()).encrypt(ctx, key)?,
590 password: self.new_password.as_ref().map(|password| {
591 let password = bitwarden_crypto::pbkdf2(password.as_bytes(), &k, SEND_ITERATIONS);
592 B64::from(password.as_slice()).to_string()
593 }),
594
595 r#type: self.r#type,
596 file: self.file.encrypt_composite(ctx, send_key)?,
597 text: self.text.encrypt_composite(ctx, send_key)?,
598
599 max_access_count: self.max_access_count,
600 access_count: self.access_count,
601 disabled: self.disabled,
602 hide_email: self.hide_email,
603
604 revision_date: self.revision_date,
605 deletion_date: self.deletion_date,
606 expiration_date: self.expiration_date,
607
608 emails: (!self.emails.is_empty()).then(|| self.emails.join(",")),
609 auth_type: self.auth_type,
610 })
611 }
612}
613
614impl TryFrom<SendResponseModel> for Send {
615 type Error = SendParseError;
616
617 fn try_from(send: SendResponseModel) -> Result<Self, Self::Error> {
618 let auth_type = match send.auth_type {
619 Some(t) => t.try_into()?,
620 None => {
621 if send.password.is_some() {
622 AuthType::Password
623 } else if send.emails.is_some() {
624 AuthType::Email
625 } else {
626 AuthType::None
627 }
628 }
629 };
630 Ok(Send {
631 id: send.id.map(SendId::new),
632 access_id: send.access_id,
633 name: require!(send.name).parse()?,
634 notes: EncString::try_from_optional(send.notes)?,
635 key: require!(send.key).parse()?,
636 password: send.password,
637 r#type: require!(send.r#type).try_into()?,
638 file: send.file.map(|f| (*f).try_into()).transpose()?,
639 text: send.text.map(|t| (*t).try_into()).transpose()?,
640 max_access_count: send.max_access_count.map(|s| s as u32),
641 access_count: require!(send.access_count) as u32,
642 disabled: send.disabled.unwrap_or(false),
643 hide_email: send.hide_email.unwrap_or(false),
644 revision_date: require!(send.revision_date).parse()?,
645 deletion_date: require!(send.deletion_date).parse()?,
646 expiration_date: send.expiration_date.map(|s| s.parse()).transpose()?,
647 emails: send.emails,
648 auth_type,
649 })
650 }
651}
652
653impl TryFrom<bitwarden_api_api::models::SendType> for SendType {
654 type Error = bitwarden_core::MissingFieldError;
655
656 fn try_from(t: bitwarden_api_api::models::SendType) -> Result<Self, Self::Error> {
657 Ok(match t {
658 bitwarden_api_api::models::SendType::Text => SendType::Text,
659 bitwarden_api_api::models::SendType::File => SendType::File,
660 bitwarden_api_api::models::SendType::Item => SendType::Item,
661 bitwarden_api_api::models::SendType::__Unknown(_) => {
662 return Err(bitwarden_core::MissingFieldError("type"));
663 }
664 })
665 }
666}
667
668impl From<SendType> for bitwarden_api_api::models::SendType {
669 fn from(t: SendType) -> Self {
670 match t {
671 SendType::Text => bitwarden_api_api::models::SendType::Text,
672 SendType::File => bitwarden_api_api::models::SendType::File,
673 SendType::Item => bitwarden_api_api::models::SendType::Item,
674 }
675 }
676}
677
678impl TryFrom<bitwarden_api_api::models::AuthType> for AuthType {
679 type Error = bitwarden_core::MissingFieldError;
680
681 fn try_from(value: bitwarden_api_api::models::AuthType) -> Result<Self, Self::Error> {
682 Ok(match value {
683 bitwarden_api_api::models::AuthType::Email => AuthType::Email,
684 bitwarden_api_api::models::AuthType::Password => AuthType::Password,
685 bitwarden_api_api::models::AuthType::None => AuthType::None,
686 bitwarden_api_api::models::AuthType::__Unknown(_) => {
687 return Err(bitwarden_core::MissingFieldError("auth_type"));
688 }
689 })
690 }
691}
692
693impl From<AuthType> for bitwarden_api_api::models::AuthType {
694 fn from(value: AuthType) -> Self {
695 match value {
696 AuthType::Email => bitwarden_api_api::models::AuthType::Email,
697 AuthType::Password => bitwarden_api_api::models::AuthType::Password,
698 AuthType::None => bitwarden_api_api::models::AuthType::None,
699 }
700 }
701}
702
703impl From<SendFile> for SendFileModel {
704 fn from(file: SendFile) -> Self {
705 SendFileModel {
706 id: file.id,
707 file_name: Some(file.file_name.to_string()),
708 size: file.size,
709 size_name: file.size_name,
710 }
711 }
712}
713
714impl From<SendText> for SendTextModel {
715 fn from(text: SendText) -> Self {
716 SendTextModel {
717 text: text.text.map(|text| text.to_string()),
718 hidden: Some(text.hidden),
719 }
720 }
721}
722
723impl TryFrom<SendFileModel> for SendFile {
724 type Error = SendParseError;
725
726 fn try_from(file: SendFileModel) -> Result<Self, Self::Error> {
727 Ok(SendFile {
728 id: file.id,
729 file_name: require!(file.file_name).parse()?,
730 size: file.size.map(|v| v.to_string()),
731 size_name: file.size_name,
732 })
733 }
734}
735
736impl TryFrom<SendTextModel> for SendText {
737 type Error = SendParseError;
738
739 fn try_from(text: SendTextModel) -> Result<Self, Self::Error> {
740 Ok(SendText {
741 text: EncString::try_from_optional(text.text)?,
742 hidden: text.hidden.unwrap_or(false),
743 })
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use bitwarden_core::key_management::create_test_crypto_with_user_key;
750 use bitwarden_crypto::SymmetricCryptoKey;
751
752 use super::*;
753
754 #[test]
755 fn test_get_send_key() {
756 let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
758 let crypto = create_test_crypto_with_user_key(user_key);
759 let mut ctx = crypto.context();
760
761 let send_key = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g="
762 .parse()
763 .unwrap();
764
765 let send_key = Send::get_key(&mut ctx, &send_key, SymmetricKeySlotId::User).unwrap();
767 #[allow(deprecated)]
768 let send_key = ctx.dangerous_get_symmetric_key(send_key).unwrap();
769 let send_key_b64 = send_key.to_base64();
770 assert_eq!(
771 send_key_b64.to_string(),
772 "IR9ImHGm6rRuIjiN7csj94bcZR5WYTJj5GtNfx33zm6tJCHUl+QZlpNPba8g2yn70KnOHsAODLcR0um6E3MAlg=="
773 );
774 }
775
776 #[test]
777 pub fn test_decrypt() {
778 let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
779 let crypto = create_test_crypto_with_user_key(user_key);
780
781 let send = Send {
782 id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
783 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
784 r#type: SendType::Text,
785 name: "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=".parse()
786 .unwrap(),
787 notes: None,
788 file: None,
789 text: Some(SendText {
790 text: "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=".parse().ok(),
791 hidden: false,
792 }),
793 key: "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=".parse().unwrap(),
794 max_access_count: None,
795 access_count: 0,
796 password: None,
797 disabled: false,
798 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
799 expiration_date: None,
800 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
801 hide_email: false,
802 emails: None,
803 auth_type: AuthType::None,
804 };
805
806 let view: SendView = crypto.decrypt(&send).unwrap();
807
808 let expected = SendView {
809 id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
810 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
811 name: "Test".to_string(),
812 notes: None,
813 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
814 new_password: None,
815 has_password: false,
816 r#type: SendType::Text,
817 file: None,
818 text: Some(SendTextView {
819 text: Some("This is a test".to_owned()),
820 hidden: false,
821 }),
822 max_access_count: None,
823 access_count: 0,
824 disabled: false,
825 hide_email: false,
826 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
827 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
828 expiration_date: None,
829 emails: Vec::new(),
830 auth_type: AuthType::None,
831 };
832
833 assert_eq!(view, expected);
834 }
835
836 #[test]
837 pub fn test_encrypt() {
838 let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
839 let crypto = create_test_crypto_with_user_key(user_key);
840
841 let view = SendView {
842 id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
843 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
844 name: "Test".to_string(),
845 notes: None,
846 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
847 new_password: None,
848 has_password: false,
849 r#type: SendType::Text,
850 file: None,
851 text: Some(SendTextView {
852 text: Some("This is a test".to_owned()),
853 hidden: false,
854 }),
855 max_access_count: None,
856 access_count: 0,
857 disabled: false,
858 hide_email: false,
859 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
860 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
861 expiration_date: None,
862 emails: Vec::new(),
863 auth_type: AuthType::None,
864 };
865
866 let v: SendView = crypto
868 .decrypt(&crypto.encrypt(view.clone()).unwrap())
869 .unwrap();
870 assert_eq!(v, view);
871 }
872
873 #[test]
874 pub fn test_create() {
875 let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
876 let crypto = create_test_crypto_with_user_key(user_key);
877
878 let view = SendView {
879 id: None,
880 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
881 name: "Test".to_string(),
882 notes: None,
883 key: None,
884 new_password: None,
885 has_password: false,
886 r#type: SendType::Text,
887 file: None,
888 text: Some(SendTextView {
889 text: Some("This is a test".to_owned()),
890 hidden: false,
891 }),
892 max_access_count: None,
893 access_count: 0,
894 disabled: false,
895 hide_email: false,
896 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
897 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
898 expiration_date: None,
899 emails: Vec::new(),
900 auth_type: AuthType::None,
901 };
902
903 let v: SendView = crypto
905 .decrypt(&crypto.encrypt(view.clone()).unwrap())
906 .unwrap();
907
908 let t = SendView { key: None, ..v };
910 assert_eq!(t, view);
911 }
912
913 #[test]
914 pub fn test_create_password() {
915 let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
916 let crypto = create_test_crypto_with_user_key(user_key);
917
918 let view = SendView {
919 id: None,
920 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
921 name: "Test".to_owned(),
922 notes: None,
923 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
924 new_password: Some("abc123".to_owned()),
925 has_password: false,
926 r#type: SendType::Text,
927 file: None,
928 text: Some(SendTextView {
929 text: Some("This is a test".to_owned()),
930 hidden: false,
931 }),
932 max_access_count: None,
933 access_count: 0,
934 disabled: false,
935 hide_email: false,
936 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
937 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
938 expiration_date: None,
939 emails: Vec::new(),
940 auth_type: AuthType::Password,
941 };
942
943 let send: Send = crypto.encrypt(view).unwrap();
944
945 assert_eq!(
946 send.password,
947 Some("vTIDfdj3FTDbejmMf+mJWpYdMXsxfeSd1Sma3sjCtiQ=".to_owned())
948 );
949 assert_eq!(send.auth_type, AuthType::Password);
950
951 let v: SendView = crypto.decrypt(&send).unwrap();
952 assert_eq!(v.new_password, None);
953 assert!(v.has_password);
954 assert_eq!(v.auth_type, AuthType::Password);
955 }
956
957 #[test]
958 pub fn test_create_email_otp() {
959 let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
960 let crypto = create_test_crypto_with_user_key(user_key);
961
962 let view = SendView {
963 id: None,
964 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
965 name: "Test".to_owned(),
966 notes: None,
967 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
968 new_password: None,
969 has_password: false,
970 r#type: SendType::Text,
971 file: None,
972 text: Some(SendTextView {
973 text: Some("This is a test".to_owned()),
974 hidden: false,
975 }),
976 max_access_count: None,
977 access_count: 0,
978 disabled: false,
979 hide_email: false,
980 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
981 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
982 expiration_date: None,
983 emails: vec![
984 String::from("[email protected]"),
985 String::from("[email protected]"),
986 ],
987 auth_type: AuthType::Email,
988 };
989
990 let send: Send = crypto.encrypt(view.clone()).unwrap();
991
992 let v: SendView = crypto.decrypt(&send).unwrap();
994
995 assert_eq!(v, view);
996 }
997
998 #[test]
999 fn test_send_into_send_with_id_request_model() {
1000 let send_id = "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().unwrap();
1001 let revision_date = DateTime::parse_from_rfc3339("2024-01-07T23:56:48Z")
1002 .unwrap()
1003 .with_timezone(&Utc);
1004 let deletion_date = DateTime::parse_from_rfc3339("2024-01-14T23:56:48Z")
1005 .unwrap()
1006 .with_timezone(&Utc);
1007 let expiration_date = DateTime::parse_from_rfc3339("2024-01-20T23:56:48Z")
1008 .unwrap()
1009 .with_timezone(&Utc);
1010
1011 let name = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
1012 let notes = "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=";
1013 let key = "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=";
1014 let file_name = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g=";
1015 let text_value = "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=";
1016
1017 let send = Send {
1018 id: Some(SendId::new(send_id)),
1019 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_string()),
1020 name: name.parse().unwrap(),
1021 notes: Some(notes.parse().unwrap()),
1022 key: key.parse().unwrap(),
1023 password: Some("hash".to_string()),
1024 r#type: SendType::File,
1025 file: Some(SendFile {
1026 id: Some("file-id".to_string()),
1027 file_name: file_name.parse().unwrap(),
1028 size: Some("1234".to_string()),
1029 size_name: Some("1.2 KB".to_string()),
1030 }),
1031 text: Some(SendText {
1032 text: Some(text_value.parse().unwrap()),
1033 hidden: true,
1034 }),
1035 max_access_count: Some(42),
1036 access_count: 0,
1037 disabled: true,
1038 hide_email: true,
1039 revision_date,
1040 deletion_date,
1041 expiration_date: Some(expiration_date),
1042 emails: Some("[email protected],[email protected]".to_string()),
1043 auth_type: AuthType::Email,
1044 };
1045
1046 let model: SendWithIdRequestModel = send.into();
1047
1048 assert_eq!(model.id, send_id);
1049 assert_eq!(
1050 model.r#type,
1051 Some(bitwarden_api_api::models::SendType::File)
1052 );
1053 assert_eq!(
1054 model.auth_type,
1055 Some(bitwarden_api_api::models::AuthType::Email)
1056 );
1057 assert_eq!(model.file_length, Some(1234));
1058 assert_eq!(model.name.as_deref(), Some(name));
1059 assert_eq!(model.notes.as_deref(), Some(notes));
1060 assert_eq!(model.key, key);
1061 assert_eq!(model.max_access_count, Some(42));
1062 assert_eq!(
1063 model
1064 .expiration_date
1065 .unwrap()
1066 .parse::<DateTime<Utc>>()
1067 .unwrap(),
1068 expiration_date
1069 );
1070 assert_eq!(
1071 model.deletion_date.parse::<DateTime<Utc>>().unwrap(),
1072 deletion_date
1073 );
1074 assert_eq!(model.password.as_deref(), Some("hash"));
1075 assert_eq!(
1076 model.emails.as_deref(),
1077 Some("[email protected],[email protected]")
1078 );
1079 assert!(model.disabled);
1080 assert_eq!(model.hide_email, Some(true));
1081
1082 let file = model.file.unwrap();
1083 assert_eq!(file.id.as_deref(), Some("file-id"));
1084 assert_eq!(file.file_name.as_deref(), Some(file_name));
1085 assert_eq!(file.size.as_deref(), Some("1234"));
1086 assert_eq!(file.size_name.as_deref(), Some("1.2 KB"));
1087
1088 let text = model.text.unwrap();
1089 assert_eq!(text.text.as_deref(), Some(text_value));
1090 assert_eq!(text.hidden, Some(true));
1091 }
1092
1093 #[test]
1094 fn auth_data_hashed_password_returns_key_b64_verbatim() {
1095 let key_b64 = "pretend-this-is-a-pbkdf2-output==".to_string();
1100 let auth = SendAuthType::HashedPassword {
1101 key_b64: key_b64.clone(),
1102 };
1103
1104 let (password, emails) = auth.auth_data(b"any-send-key-bytes-here");
1105
1106 assert_eq!(password, Some(key_b64));
1107 assert_eq!(emails, None);
1108 }
1109
1110 #[test]
1111 fn auth_data_hashed_and_plaintext_diverge_for_same_input() {
1112 let same_string = "abc123".to_string();
1117 let send_key = b"send-key-salt-bytes";
1118
1119 let (plaintext_out, _) = SendAuthType::Password {
1120 password: same_string.clone(),
1121 }
1122 .auth_data(send_key);
1123 let (hashed_out, _) = SendAuthType::HashedPassword {
1124 key_b64: same_string,
1125 }
1126 .auth_data(send_key);
1127
1128 assert_ne!(
1129 plaintext_out, hashed_out,
1130 "Plaintext path must run PBKDF2; HashedPassword path must not"
1131 );
1132 }
1133
1134 #[test]
1135 fn auth_type_for_hashed_password_maps_to_password() {
1136 assert_eq!(
1139 SendAuthType::Password {
1140 password: "p".to_string()
1141 }
1142 .auth_type(),
1143 AuthType::Password,
1144 );
1145 assert_eq!(
1146 SendAuthType::HashedPassword {
1147 key_b64: "k".to_string()
1148 }
1149 .auth_type(),
1150 AuthType::Password,
1151 );
1152 }
1153
1154 #[test]
1158 fn send_auth_type_round_trips_through_json() {
1159 let cases = [
1160 (SendAuthType::None, serde_json::json!({"type": "none"})),
1161 (
1162 SendAuthType::Password {
1163 password: "hunter2".to_string(),
1164 },
1165 serde_json::json!({"type": "password", "password": "hunter2"}),
1166 ),
1167 (
1168 SendAuthType::HashedPassword {
1169 key_b64: "deadbeef==".to_string(),
1170 },
1171 serde_json::json!({"type": "hashedPassword", "keyB64": "deadbeef=="}),
1172 ),
1173 (
1174 SendAuthType::Emails {
1175 emails: vec!["[email protected]".to_string()],
1176 },
1177 serde_json::json!({"type": "emails", "emails": ["[email protected]"]}),
1178 ),
1179 ];
1180 for (value, expected) in cases {
1181 let serialized = serde_json::to_value(&value).expect("serialize");
1182 assert_eq!(serialized, expected, "wire shape mismatch for {value:?}");
1183 let deserialized: SendAuthType =
1184 serde_json::from_value(serialized).expect("round-trip");
1185 assert_eq!(deserialized, value, "round-trip mismatch for {value:?}");
1186 }
1187 }
1188
1189 #[test]
1190 fn typed_constructors_produce_expected_variants() {
1191 assert_eq!(
1192 SendAuthType::from_plaintext_password("hunter2".to_string()),
1193 SendAuthType::Password {
1194 password: "hunter2".to_string()
1195 },
1196 );
1197 assert_eq!(
1198 SendAuthType::from_hashed_password("deadbeef==".to_string()),
1199 SendAuthType::HashedPassword {
1200 key_b64: "deadbeef==".to_string()
1201 },
1202 );
1203 }
1204}