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    /// Item-based send
97    Item = 2,
98}
99
100/// Indicates the authentication strategy to use when accessing a Send
101#[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-based OTP authentication
107    Email = 0,
108
109    /// Password-based authentication
110    Password = 1,
111
112    /// No authentication required
113    None = 2,
114}
115
116/// Type-safe authentication method for a Send, including the authentication data.
117/// This ensures that password and email authentication are mutually exclusive.
118#[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    /// No authentication required
124    None,
125    /// Password-based authentication. The SDK derives the wire-format `keyB64` via PBKDF2
126    /// over the send key.
127    Password {
128        /// The plaintext password the recipient will enter to access the Send.
129        password: String,
130    },
131    /// Pre-derived password. The caller has already run PBKDF2 client-side and supplies the
132    /// resulting base64-encoded hash; the SDK forwards it verbatim. Use this when the
133    /// hashing happens outside the SDK (e.g. the legacy TypeScript clients that derive in
134    /// `SendService.encrypt`). For new code that holds a plaintext password, use
135    /// `Password { ... }` and let the SDK do the derivation.
136    HashedPassword {
137        /// Base64-encoded PBKDF2 output (`keyB64`) ready for the wire.
138        #[serde(rename = "keyB64")]
139        key_b64: String,
140    },
141    /// Email-based OTP authentication
142    Emails {
143        /// List of email addresses that will receive OTP codes
144        emails: Vec<String>,
145    },
146}
147
148impl SendAuthType {
149    /// Construct a `Password` variant from a plaintext password. The SDK will run PBKDF2
150    /// during encryption.
151    pub fn from_plaintext_password(password: String) -> Self {
152        SendAuthType::Password { password }
153    }
154
155    /// Construct a `HashedPassword` variant from an already-derived `keyB64`. The SDK
156    /// forwards it verbatim — no further derivation. Misuse (passing plaintext here)
157    /// produces an unsatisfiable server-side hash.
158    pub fn from_hashed_password(key_b64: String) -> Self {
159        SendAuthType::HashedPassword { key_b64 }
160    }
161
162    /// Returns the AuthType discriminant for this authentication method
163    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    /// Validates that the auth configuration is valid.
174    /// Returns an error if `Emails` is used with an empty list.
175    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    /// Returns `(password, emails)` for the wire request. For `Password`, runs PBKDF2 over
185    /// the plaintext using `k` as the salt. For `HashedPassword`, forwards the supplied
186    /// `keyB64` verbatim — `k` is unused on that branch.
187    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/// View model for decrypted Send type
208#[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-based send
212    File(SendFileView),
213    /// Text-based send
214    Text(SendTextView),
215}
216
217/// Type alias for the tuple returned by SendViewType::into_api_models
218type 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    /// Email addresses for OTP authentication (comma-separated).
286    ///
287    /// **Note**: Mutually exclusive with `password`. If both `password` and `emails` are
288    /// set, password authentication takes precedence and email OTP is ignored.
289    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            // TODO: Implement logic for item-based Sends
316            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    /// Base64 encoded key
341    pub key: Option<String>,
342    /// Replace or add a password to an existing send. The SDK will always return None when
343    /// decrypting a [Send]
344    /// TODO: We should revisit this, one variant is to have `[Create, Update]SendView` DTOs.
345    pub new_password: Option<String>,
346    /// Denote if an existing send has a password. The SDK will ignore this value when creating or
347    /// updating sends.
348    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    /// Email addresses for OTP authentication.
364    /// **Note**: Mutually exclusive with `new_password`. If both are set, only password
365    /// authentication will be used. When creating or editing sends, use [crate::SendAuthType]
366    /// to ensure mutual exclusivity at the type level.
367    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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
487        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
488        // the stretched key
489        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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
536        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
537        // the stretched key
538        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        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
565        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
566        // the stretched key
567        let k = match (&self.key, &self.id) {
568            // Existing send, decrypt key
569            (Some(k), _) => B64Url::try_from(k.as_str())
570                .map_err(|_| CryptoError::InvalidKey)?
571                .as_bytes()
572                .to_vec(),
573            // New send, generate random key
574            (None, None) => {
575                let key = generate_random_bytes::<[u8; 16]>();
576                key.to_vec()
577            }
578            // Existing send without key
579            _ => 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        // Initialize user encryption with some test data
757        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        // Get the send key
766        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        // Re-encrypt and decrypt again to ensure encrypt works
867        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        // Re-encrypt and decrypt again to ensure encrypt works
904        let v: SendView = crypto
905            .decrypt(&crypto.encrypt(view.clone()).unwrap())
906            .unwrap();
907
908        // Ignore key when comparing
909        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        // Verify decrypted view matches original prior to encrypting
993        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        // `HashedPassword` is the wire-form contract: the caller supplies the already-
1096        // derived base64 hash and the SDK must NOT run PBKDF2 again. We assert by
1097        // checking that the returned password equals the input byte-for-byte, including
1098        // for inputs that wouldn't be valid base64 (proves no decode/re-encode happens).
1099        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        // Sanity check: passing the same string through `Password` and `HashedPassword`
1113        // produces different wire outputs, so a mis-routed caller (plaintext into
1114        // `HashedPassword`) fails loudly server-side rather than silently producing the
1115        // same hash as the plaintext path would.
1116        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        // Both `Password` and `HashedPassword` produce `authType = Password` on the wire;
1137        // the server doesn't distinguish.
1138        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    /// Pins the wire shape of `SendAuthType` including the new `HashedPassword` variant
1155    /// (`"type": "hashedPassword"` under camelCase rename). Same pattern as the existing
1156    /// regression tests that pin internally-tagged serde enums.
1157    #[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}