Skip to main content

bitwarden_send/
send.rs

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/// Error returned when `SendAuthType::Emails` is constructed with an empty email list.
28#[derive(Debug, Error)]
29#[error("Email authentication requires at least one email address")]
30pub struct EmptyEmailListError;
31
32/// File-based send content
33#[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    /// The file's ID
39    pub id: Option<String>,
40    /// The encrypted file name
41    pub file_name: EncString,
42    /// The file size in bytes as a string
43    pub size: Option<String>,
44    /// Readable size, ex: "4.2 KB" or "1.43 GB"
45    pub size_name: Option<String>,
46}
47
48/// View model for decrypted SendFile
49#[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    /// The file's ID
55    pub id: Option<String>,
56    /// The file name
57    pub file_name: String,
58    /// The file size in bytes as a string
59    pub size: Option<String>,
60    /// Readable size, ex: "4.2 KB" or "1.43 GB"
61    pub size_name: Option<String>,
62}
63
64/// Text-based send content
65#[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/// View model for decrypted SendText
75#[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    /// The text content of the send
81    pub text: Option<String>,
82    /// Whether the text is hidden-by-default (masked as ********).
83    pub hidden: bool,
84}
85
86/// The type of Send, either text or file
87#[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-based send
93    Text = 0,
94    /// File-based send
95    File = 1,
96}
97
98/// Indicates the authentication strategy to use when accessing a Send
99#[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-based OTP authentication
105    Email = 0,
106
107    /// Password-based authentication
108    Password = 1,
109
110    /// No authentication required
111    None = 2,
112}
113
114/// Type-safe authentication method for a Send, including the authentication data.
115/// This ensures that password and email authentication are mutually exclusive.
116#[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    /// No authentication required
122    None,
123    /// Password-based authentication. The SDK derives the wire-format `keyB64` via PBKDF2
124    /// over the send key.
125    Password {
126        /// The plaintext password the recipient will enter to access the Send.
127        password: String,
128    },
129    /// Pre-derived password. The caller has already run PBKDF2 client-side and supplies the
130    /// resulting base64-encoded hash; the SDK forwards it verbatim. Use this when the
131    /// hashing happens outside the SDK (e.g. the legacy TypeScript clients that derive in
132    /// `SendService.encrypt`). For new code that holds a plaintext password, use
133    /// `Password { ... }` and let the SDK do the derivation.
134    HashedPassword {
135        /// Base64-encoded PBKDF2 output (`keyB64`) ready for the wire.
136        #[serde(rename = "keyB64")]
137        key_b64: String,
138    },
139    /// Email-based OTP authentication
140    Emails {
141        /// List of email addresses that will receive OTP codes
142        emails: Vec<String>,
143    },
144}
145
146impl SendAuthType {
147    /// Construct a `Password` variant from a plaintext password. The SDK will run PBKDF2
148    /// during encryption.
149    pub fn from_plaintext_password(password: String) -> Self {
150        SendAuthType::Password { password }
151    }
152
153    /// Construct a `HashedPassword` variant from an already-derived `keyB64`. The SDK
154    /// forwards it verbatim — no further derivation. Misuse (passing plaintext here)
155    /// produces an unsatisfiable server-side hash.
156    pub fn from_hashed_password(key_b64: String) -> Self {
157        SendAuthType::HashedPassword { key_b64 }
158    }
159
160    /// Returns the AuthType discriminant for this authentication method
161    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    /// Validates that the auth configuration is valid.
172    /// Returns an error if `Emails` is used with an empty list.
173    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    /// Returns `(password, emails)` for the wire request. For `Password`, runs PBKDF2 over
183    /// the plaintext using `k` as the salt. For `HashedPassword`, forwards the supplied
184    /// `keyB64` verbatim — `k` is unused on that branch.
185    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/// View model for decrypted Send type
206#[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-based send
210    File(SendFileView),
211    /// Text-based send
212    Text(SendTextView),
213}
214
215/// Type alias for the tuple returned by SendViewType::into_api_models
216type 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    /// Email addresses for OTP authentication (comma-separated).
284    ///
285    /// **Note**: Mutually exclusive with `password`. If both `password` and `emails` are
286    /// set, password authentication takes precedence and email OTP is ignored.
287    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    /// Base64 encoded key
337    pub key: Option<String>,
338    /// Replace or add a password to an existing send. The SDK will always return None when
339    /// decrypting a [Send]
340    /// TODO: We should revisit this, one variant is to have `[Create, Update]SendView` DTOs.
341    pub new_password: Option<String>,
342    /// Denote if an existing send has a password. The SDK will ignore this value when creating or
343    /// updating sends.
344    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    /// Email addresses for OTP authentication.
360    /// **Note**: Mutually exclusive with `new_password`. If both are set, only password
361    /// authentication will be used. When creating or editing sends, use [crate::SendAuthType]
362    /// to ensure mutual exclusivity at the type level.
363    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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
483        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
484        // the stretched key
485        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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
532        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
533        // the stretched key
534        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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
561        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
562        // the stretched key
563        let k = match (&self.key, &self.id) {
564            // Existing send, decrypt key
565            (Some(k), _) => B64Url::try_from(k.as_str())
566                .map_err(|_| CryptoError::InvalidKey)?
567                .as_bytes()
568                .to_vec(),
569            // New send, generate random key
570            (None, None) => {
571                let key = generate_random_bytes::<[u8; 16]>();
572                key.to_vec()
573            }
574            // Existing send without key
575            _ => 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        // Initialize user encryption with some test data
751        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        // Get the send key
760        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        // Re-encrypt and decrypt again to ensure encrypt works
861        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        // Re-encrypt and decrypt again to ensure encrypt works
898        let v: SendView = crypto
899            .decrypt(&crypto.encrypt(view.clone()).unwrap())
900            .unwrap();
901
902        // Ignore key when comparing
903        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        // Verify decrypted view matches original prior to encrypting
987        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        // `HashedPassword` is the wire-form contract: the caller supplies the already-
1090        // derived base64 hash and the SDK must NOT run PBKDF2 again. We assert by
1091        // checking that the returned password equals the input byte-for-byte, including
1092        // for inputs that wouldn't be valid base64 (proves no decode/re-encode happens).
1093        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        // Sanity check: passing the same string through `Password` and `HashedPassword`
1107        // produces different wire outputs, so a mis-routed caller (plaintext into
1108        // `HashedPassword`) fails loudly server-side rather than silently producing the
1109        // same hash as the plaintext path would.
1110        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        // Both `Password` and `HashedPassword` produce `authType = Password` on the wire;
1131        // the server doesn't distinguish.
1132        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    /// Pins the wire shape of `SendAuthType` including the new `HashedPassword` variant
1149    /// (`"type": "hashedPassword"` under camelCase rename). Same pattern as the existing
1150    /// regression tests that pin internally-tagged serde enums.
1151    #[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}