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