Skip to main content

bitwarden_send/
send.rs

1use bitwarden_api_api::models::{
2    SendDataModel, 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 bitwarden_vault::{Cipher, CipherView, EncryptMode};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_repr::{Deserialize_repr, Serialize_repr};
18use thiserror::Error;
19use zeroize::Zeroizing;
20#[cfg(feature = "wasm")]
21use {tsify::Tsify, wasm_bindgen::prelude::*};
22
23use crate::{SendParseError, access::SEND_KEY_LEN, error::SendItemDeserializationFailureError};
24pub const SEND_ITERATIONS: u32 = 100_000;
25pub const DEFAULT_SEND_ENCRYPTION: SendEncryptionType = SendEncryptionType::V1;
26
27uuid_newtype!(pub SendId);
28
29/// Error returned when `SendAuthType::Emails` is constructed with an empty email list.
30#[derive(Debug, Error)]
31#[error("Email authentication requires at least one email address")]
32pub struct EmptyEmailListError;
33
34/// File-based send content
35#[derive(Serialize, Deserialize, Debug, Clone)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
38#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
39pub struct SendFile {
40    /// The file's ID
41    pub id: Option<String>,
42    /// The encrypted file name
43    pub file_name: EncString,
44    /// The file size in bytes as a string
45    pub size: Option<String>,
46    /// Readable size, ex: "4.2 KB" or "1.43 GB"
47    pub size_name: Option<String>,
48}
49
50/// View model for decrypted SendFile
51#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
54#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
55pub struct SendFileView {
56    /// The file's ID
57    pub id: Option<String>,
58    /// The file name
59    pub file_name: String,
60    /// The file size in bytes as a string
61    pub size: Option<String>,
62    /// Readable size, ex: "4.2 KB" or "1.43 GB"
63    pub size_name: Option<String>,
64}
65
66/// Text-based send content
67#[derive(Serialize, Deserialize, Debug, Clone)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
70#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
71pub struct SendText {
72    pub text: Option<EncString>,
73    pub hidden: bool,
74}
75
76/// View model for decrypted SendItem
77#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
80#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
81pub struct SendItemView {
82    /// The item content of the send
83    pub data: CipherView,
84}
85
86/// Item-based send content
87#[derive(Serialize, Deserialize, Debug, Clone)]
88#[serde(rename_all = "camelCase", deny_unknown_fields)]
89#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
90#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
91pub struct SendItem {
92    pub encryption_version: SendEncryptionType,
93    pub data: Cipher,
94}
95
96/// View model for decrypted SendText
97#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
100#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
101pub struct SendTextView {
102    /// The text content of the send
103    pub text: Option<String>,
104    /// Whether the text is hidden-by-default (masked as ********).
105    pub hidden: bool,
106}
107
108/// The type of Send, either text, file, or item
109#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
110#[repr(u8)]
111#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
112#[cfg_attr(feature = "wasm", wasm_bindgen)]
113pub enum SendType {
114    /// Text-based send
115    Text = 0,
116    /// File-based send
117    File = 1,
118    /// Item-based send
119    Item = 2,
120}
121
122/// Indicates the authentication strategy to use when accessing a Send
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
124#[repr(u8)]
125#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
126#[cfg_attr(feature = "wasm", wasm_bindgen)]
127pub enum AuthType {
128    /// Email-based OTP authentication
129    Email = 0,
130
131    /// Password-based authentication
132    Password = 1,
133
134    /// No authentication required
135    None = 2,
136}
137
138/// Indicates the version of Send data encryption that is being used
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
140#[repr(u8)]
141#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
142#[cfg_attr(feature = "wasm", wasm_bindgen)]
143pub enum SendEncryptionType {
144    /// V1 encryption (field by field)
145    V1 = 1,
146}
147
148/// Type-safe authentication method for a Send, including the authentication data.
149/// This ensures that password and email authentication are mutually exclusive.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "type", rename_all = "camelCase")]
152#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
153#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
154pub enum SendAuthType {
155    /// No authentication required
156    None,
157    /// Password-based authentication. The SDK derives the wire-format `keyB64` via PBKDF2
158    /// over the send key.
159    Password {
160        /// The plaintext password the recipient will enter to access the Send.
161        password: String,
162    },
163    /// Pre-derived password. The caller has already run PBKDF2 client-side and supplies the
164    /// resulting base64-encoded hash; the SDK forwards it verbatim. Use this when the
165    /// hashing happens outside the SDK (e.g. the legacy TypeScript clients that derive in
166    /// `SendService.encrypt`). For new code that holds a plaintext password, use
167    /// `Password { ... }` and let the SDK do the derivation.
168    HashedPassword {
169        /// Base64-encoded PBKDF2 output (`keyB64`) ready for the wire.
170        #[serde(rename = "keyB64")]
171        key_b64: String,
172    },
173    /// Email-based OTP authentication
174    Emails {
175        /// List of email addresses that will receive OTP codes
176        emails: Vec<String>,
177    },
178}
179
180impl SendAuthType {
181    /// Construct a `Password` variant from a plaintext password. The SDK will run PBKDF2
182    /// during encryption.
183    pub fn from_plaintext_password(password: String) -> Self {
184        SendAuthType::Password { password }
185    }
186
187    /// Construct a `HashedPassword` variant from an already-derived `keyB64`. The SDK
188    /// forwards it verbatim — no further derivation. Misuse (passing plaintext here)
189    /// produces an unsatisfiable server-side hash.
190    pub fn from_hashed_password(key_b64: String) -> Self {
191        SendAuthType::HashedPassword { key_b64 }
192    }
193
194    /// Returns the AuthType discriminant for this authentication method
195    pub fn auth_type(&self) -> AuthType {
196        match self {
197            SendAuthType::None => AuthType::None,
198            SendAuthType::Password { .. } | SendAuthType::HashedPassword { .. } => {
199                AuthType::Password
200            }
201            SendAuthType::Emails { .. } => AuthType::Email,
202        }
203    }
204
205    /// Validates that the auth configuration is valid.
206    /// Returns an error if `Emails` is used with an empty list.
207    pub(crate) fn validate(&self) -> Result<(), EmptyEmailListError> {
208        if let SendAuthType::Emails { emails } = self
209            && emails.is_empty()
210        {
211            return Err(EmptyEmailListError);
212        }
213        Ok(())
214    }
215
216    /// Returns `(password, emails)` for the wire request. For `Password`, runs PBKDF2 over
217    /// the plaintext using `k` as the salt. For `HashedPassword`, forwards the supplied
218    /// `keyB64` verbatim — `k` is unused on that branch.
219    pub(crate) fn auth_data(&self, k: &[u8]) -> (Option<String>, Option<String>) {
220        match self {
221            SendAuthType::Password { password } => {
222                let hashed = bitwarden_crypto::pbkdf2(password.as_bytes(), k, SEND_ITERATIONS);
223                (Some(B64::from(hashed.as_slice()).to_string()), None)
224            }
225            SendAuthType::HashedPassword { key_b64 } => (Some(key_b64.clone()), None),
226            SendAuthType::Emails { emails } => {
227                let emails_str = if emails.is_empty() {
228                    None
229                } else {
230                    Some(emails.join(","))
231                };
232                (None, emails_str)
233            }
234            SendAuthType::None => (None, None),
235        }
236    }
237}
238
239/// View model for decrypted Send type
240#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
241#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
242pub enum SendViewType {
243    /// File-based send
244    File(SendFileView),
245    /// Text-based send
246    Text(SendTextView),
247    /// Item-based send
248    Item(Box<SendItemView>),
249}
250
251/// Type alias for the tuple returned by SendViewType::into_api_models
252type SendApiModels = (
253    bitwarden_api_api::models::SendType,
254    Option<Box<bitwarden_api_api::models::SendFileModel>>,
255    Option<Box<bitwarden_api_api::models::SendTextModel>>,
256    Option<Box<bitwarden_api_api::models::SendDataModel>>,
257);
258
259impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendApiModels> for SendViewType {
260    fn encrypt_composite(
261        &self,
262        ctx: &mut KeyStoreContext<KeySlotIds>,
263        key: SymmetricKeySlotId,
264    ) -> Result<SendApiModels, CryptoError> {
265        match self {
266            SendViewType::File(f) => Ok((
267                bitwarden_api_api::models::SendType::File,
268                Some(Box::new(bitwarden_api_api::models::SendFileModel {
269                    id: f.id.clone(),
270                    file_name: Some(f.file_name.encrypt(ctx, key)?.to_string()),
271                    size: f.size.clone(),
272                    size_name: f.size_name.clone(),
273                })),
274                None,
275                None,
276            )),
277            SendViewType::Text(t) => Ok((
278                bitwarden_api_api::models::SendType::Text,
279                None,
280                Some(Box::new(bitwarden_api_api::models::SendTextModel {
281                    text: t
282                        .text
283                        .as_ref()
284                        .map(|txt| txt.encrypt(ctx, key))
285                        .transpose()?
286                        .map(|e| e.to_string()),
287                    hidden: Some(t.hidden),
288                })),
289                None,
290            )),
291            SendViewType::Item(i) => {
292                let encrypted = i.encrypt_composite(ctx, key)?;
293                let serialized_cipher =
294                    serde_json::to_string(&encrypted.data).unwrap_or("{}".to_string());
295                Ok((
296                    bitwarden_api_api::models::SendType::Item,
297                    None,
298                    None,
299                    Some(Box::new(bitwarden_api_api::models::SendDataModel {
300                        encryption_version: Some(DEFAULT_SEND_ENCRYPTION.into()),
301                        data: Some(serialized_cipher),
302                    })),
303                ))
304            }
305        }
306    }
307}
308
309#[allow(missing_docs)]
310#[derive(Serialize, Deserialize, Debug, Clone)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
313#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
314pub struct Send {
315    pub id: Option<SendId>,
316    pub access_id: Option<String>,
317
318    pub name: EncString,
319    pub notes: Option<EncString>,
320    pub key: EncString,
321    pub password: Option<String>,
322
323    pub r#type: SendType,
324    pub file: Option<SendFile>,
325    pub text: Option<SendText>,
326    pub data: Option<SendItem>,
327
328    pub max_access_count: Option<u32>,
329    pub access_count: u32,
330    pub disabled: bool,
331    pub hide_email: bool,
332
333    pub revision_date: DateTime<Utc>,
334    pub deletion_date: DateTime<Utc>,
335    pub expiration_date: Option<DateTime<Utc>>,
336
337    /// Email addresses for OTP authentication (comma-separated).
338    ///
339    /// **Note**: Mutually exclusive with `password`. If both `password` and `emails` are
340    /// set, password authentication takes precedence and email OTP is ignored.
341    pub emails: Option<String>,
342    pub auth_type: AuthType,
343}
344
345bitwarden_state::register_repository_item!(SendId => Send, "Send");
346
347impl From<Send> for SendWithIdRequestModel {
348    fn from(send: Send) -> Self {
349        let file_length = send.file.as_ref().and_then(|file| {
350            file.size
351                .as_deref()
352                .and_then(|size| size.parse::<i64>().ok())
353        });
354
355        SendWithIdRequestModel {
356            r#type: Some(send.r#type.into()),
357            auth_type: Some(send.auth_type.into()),
358            file_length,
359            name: Some(send.name.to_string()),
360            notes: send.notes.map(|notes| notes.to_string()),
361            key: send.key.to_string(),
362            max_access_count: send.max_access_count.map(|count| count as i32),
363            expiration_date: send.expiration_date.map(|date| date.to_rfc3339()),
364            deletion_date: send.deletion_date.to_rfc3339(),
365            file: send.file.map(|file| Box::new(file.into())),
366            text: send.text.map(|text| Box::new(text.into())),
367            // TODO: Implement logic for item-based Sends
368            data: None,
369            password: send.password,
370            emails: send.emails,
371            disabled: send.disabled,
372            hide_email: Some(send.hide_email),
373            id: send
374                .id
375                .expect("SendWithIdRequestModel conversion requires send id")
376                .into(),
377        }
378    }
379}
380
381#[allow(missing_docs)]
382#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
383#[serde(rename_all = "camelCase", deny_unknown_fields)]
384#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
385#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
386pub struct SendView {
387    pub id: Option<SendId>,
388    pub access_id: Option<String>,
389
390    pub name: String,
391    pub notes: Option<String>,
392    /// Base64 encoded key
393    pub key: Option<String>,
394    /// Replace or add a password to an existing send. The SDK will always return None when
395    /// decrypting a [Send]
396    /// TODO: We should revisit this, one variant is to have `[Create, Update]SendView` DTOs.
397    pub new_password: Option<String>,
398    /// Denote if an existing send has a password. The SDK will ignore this value when creating or
399    /// updating sends.
400    pub has_password: bool,
401
402    pub r#type: SendType,
403    pub file: Option<SendFileView>,
404    pub text: Option<SendTextView>,
405    pub data: Option<SendItemView>,
406
407    pub max_access_count: Option<u32>,
408    pub access_count: u32,
409    pub disabled: bool,
410    pub hide_email: bool,
411
412    pub revision_date: DateTime<Utc>,
413    pub deletion_date: DateTime<Utc>,
414    pub expiration_date: Option<DateTime<Utc>>,
415
416    /// Email addresses for OTP authentication.
417    /// **Note**: Mutually exclusive with `new_password`. If both are set, only password
418    /// authentication will be used. When creating or editing sends, use [crate::SendAuthType]
419    /// to ensure mutual exclusivity at the type level.
420    pub emails: Vec<String>,
421    pub auth_type: AuthType,
422}
423
424#[allow(missing_docs)]
425#[derive(Serialize, Deserialize, Debug)]
426#[serde(rename_all = "camelCase", deny_unknown_fields)]
427#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
428#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
429pub struct SendListView {
430    pub id: Option<SendId>,
431    pub access_id: Option<String>,
432
433    pub name: String,
434
435    pub r#type: SendType,
436    pub disabled: bool,
437
438    pub revision_date: DateTime<Utc>,
439    pub deletion_date: DateTime<Utc>,
440    pub expiration_date: Option<DateTime<Utc>>,
441
442    pub auth_type: AuthType,
443}
444
445impl Send {
446    #[allow(missing_docs)]
447    pub fn get_key(
448        ctx: &mut KeyStoreContext<KeySlotIds>,
449        send_key: &EncString,
450        enc_key: SymmetricKeySlotId,
451    ) -> Result<SymmetricKeySlotId, CryptoError> {
452        let key: Vec<u8> = send_key.decrypt(ctx, enc_key)?;
453        Self::derive_shareable_key(ctx, &key)
454    }
455
456    pub(crate) fn derive_shareable_key(
457        ctx: &mut KeyStoreContext<KeySlotIds>,
458        key: &[u8],
459    ) -> Result<SymmetricKeySlotId, CryptoError> {
460        let key = Zeroizing::new(key.try_into().map_err(|_| CryptoError::InvalidKeyLen)?);
461        ctx.derive_shareable_key(key, "send", Some("send"))
462    }
463}
464
465impl IdentifyKey<SymmetricKeySlotId> for Send {
466    fn key_identifier(&self) -> SymmetricKeySlotId {
467        SymmetricKeySlotId::User
468    }
469}
470
471impl IdentifyKey<SymmetricKeySlotId> for SendView {
472    fn key_identifier(&self) -> SymmetricKeySlotId {
473        SymmetricKeySlotId::User
474    }
475}
476
477impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendTextView> for SendText {
478    fn decrypt(
479        &self,
480        ctx: &mut KeyStoreContext<KeySlotIds>,
481        key: SymmetricKeySlotId,
482    ) -> Result<SendTextView, CryptoError> {
483        Ok(SendTextView {
484            text: self.text.decrypt(ctx, key)?,
485            hidden: self.hidden,
486        })
487    }
488}
489
490impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendText> for SendTextView {
491    fn encrypt_composite(
492        &self,
493        ctx: &mut KeyStoreContext<KeySlotIds>,
494        key: SymmetricKeySlotId,
495    ) -> Result<SendText, CryptoError> {
496        Ok(SendText {
497            text: self.text.encrypt(ctx, key)?,
498            hidden: self.hidden,
499        })
500    }
501}
502
503impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendFileView> for SendFile {
504    fn decrypt(
505        &self,
506        ctx: &mut KeyStoreContext<KeySlotIds>,
507        key: SymmetricKeySlotId,
508    ) -> Result<SendFileView, CryptoError> {
509        Ok(SendFileView {
510            id: self.id.clone(),
511            file_name: self.file_name.decrypt(ctx, key)?,
512            size: self.size.clone(),
513            size_name: self.size_name.clone(),
514        })
515    }
516}
517
518impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendFile> for SendFileView {
519    fn encrypt_composite(
520        &self,
521        ctx: &mut KeyStoreContext<KeySlotIds>,
522        key: SymmetricKeySlotId,
523    ) -> Result<SendFile, CryptoError> {
524        Ok(SendFile {
525            id: self.id.clone(),
526            file_name: self.file_name.encrypt(ctx, key)?,
527            size: self.size.clone(),
528            size_name: self.size_name.clone(),
529        })
530    }
531}
532
533impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendItemView> for SendItem {
534    fn decrypt(
535        &self,
536        ctx: &mut KeyStoreContext<KeySlotIds>,
537        key: SymmetricKeySlotId,
538    ) -> Result<SendItemView, CryptoError> {
539        let data: CipherView = self.data.decrypt(ctx, key)?;
540        Ok(SendItemView { data })
541    }
542}
543
544impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SendItem> for SendItemView {
545    fn encrypt_composite(
546        &self,
547        ctx: &mut KeyStoreContext<KeySlotIds>,
548        key: SymmetricKeySlotId,
549    ) -> Result<SendItem, CryptoError> {
550        let cipher: Cipher = EncryptMode::Legacy(self.data.clone()).encrypt_composite(ctx, key)?;
551        Ok(SendItem {
552            encryption_version: DEFAULT_SEND_ENCRYPTION,
553            data: cipher,
554        })
555    }
556}
557
558impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendView> for Send {
559    fn decrypt(
560        &self,
561        ctx: &mut KeyStoreContext<KeySlotIds>,
562        key: SymmetricKeySlotId,
563    ) -> Result<SendView, 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: Vec<u8> = self.key.decrypt(ctx, key)?;
568        let key = Send::derive_shareable_key(ctx, &k)?;
569
570        Ok(SendView {
571            id: self.id,
572            access_id: self.access_id.clone(),
573
574            name: self.name.decrypt(ctx, key).ok().unwrap_or_default(),
575            notes: self.notes.decrypt(ctx, key).ok().flatten(),
576            key: Some(B64Url::from(k).to_string()),
577            new_password: None,
578            has_password: self.password.is_some(),
579
580            r#type: self.r#type,
581            file: self.file.decrypt(ctx, key).ok().flatten(),
582            text: self.text.decrypt(ctx, key).ok().flatten(),
583            data: self.data.decrypt(ctx, key).ok().flatten(),
584
585            max_access_count: self.max_access_count,
586            access_count: self.access_count,
587            disabled: self.disabled,
588            hide_email: self.hide_email,
589
590            revision_date: self.revision_date,
591            deletion_date: self.deletion_date,
592            expiration_date: self.expiration_date,
593
594            emails: self
595                .emails
596                .as_deref()
597                .unwrap_or_default()
598                .split(',')
599                .map(|e| e.trim())
600                .filter(|e| !e.is_empty())
601                .map(String::from)
602                .collect(),
603            auth_type: self.auth_type,
604        })
605    }
606}
607
608impl Decryptable<KeySlotIds, SymmetricKeySlotId, SendListView> for Send {
609    fn decrypt(
610        &self,
611        ctx: &mut KeyStoreContext<KeySlotIds>,
612        key: SymmetricKeySlotId,
613    ) -> Result<SendListView, CryptoError> {
614        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
615        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
616        // the stretched key
617        let key = Send::get_key(ctx, &self.key, key)?;
618
619        Ok(SendListView {
620            id: self.id,
621            access_id: self.access_id.clone(),
622
623            name: self.name.decrypt(ctx, key)?,
624            r#type: self.r#type,
625
626            disabled: self.disabled,
627
628            revision_date: self.revision_date,
629            deletion_date: self.deletion_date,
630            expiration_date: self.expiration_date,
631
632            auth_type: self.auth_type,
633        })
634    }
635}
636
637impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Send> for SendView {
638    fn encrypt_composite(
639        &self,
640        ctx: &mut KeyStoreContext<KeySlotIds>,
641        key: SymmetricKeySlotId,
642    ) -> Result<Send, CryptoError> {
643        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
644        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
645        // the stretched key
646        let k = match (&self.key, &self.id) {
647            // Existing send, decrypt key
648            (Some(k), _) => B64Url::try_from(k.as_str())
649                .map_err(|_| CryptoError::InvalidKey)?
650                .as_bytes()
651                .to_vec(),
652            // New send, generate random key
653            (None, None) => {
654                let key = generate_random_bytes::<[u8; SEND_KEY_LEN]>();
655                key.to_vec()
656            }
657            // Existing send without key
658            _ => return Err(CryptoError::InvalidKey),
659        };
660        let send_key = Send::derive_shareable_key(ctx, &k)?;
661
662        Ok(Send {
663            id: self.id,
664            access_id: self.access_id.clone(),
665
666            name: self.name.encrypt(ctx, send_key)?,
667            notes: self.notes.encrypt(ctx, send_key)?,
668            key: OctetStreamBytes::from(k.clone()).encrypt(ctx, key)?,
669            password: self.new_password.as_ref().map(|password| {
670                let password = bitwarden_crypto::pbkdf2(password.as_bytes(), &k, SEND_ITERATIONS);
671                B64::from(password.as_slice()).to_string()
672            }),
673
674            r#type: self.r#type,
675            file: self.file.encrypt_composite(ctx, send_key)?,
676            text: self.text.encrypt_composite(ctx, send_key)?,
677            data: self.data.encrypt_composite(ctx, send_key)?,
678
679            max_access_count: self.max_access_count,
680            access_count: self.access_count,
681            disabled: self.disabled,
682            hide_email: self.hide_email,
683
684            revision_date: self.revision_date,
685            deletion_date: self.deletion_date,
686            expiration_date: self.expiration_date,
687
688            emails: (!self.emails.is_empty()).then(|| self.emails.join(",")),
689            auth_type: self.auth_type,
690        })
691    }
692}
693
694impl TryFrom<SendResponseModel> for Send {
695    type Error = SendParseError;
696
697    fn try_from(send: SendResponseModel) -> Result<Self, Self::Error> {
698        let auth_type = match send.auth_type {
699            Some(t) => t.try_into()?,
700            None => {
701                if send.password.is_some() {
702                    AuthType::Password
703                } else if send.emails.is_some() {
704                    AuthType::Email
705                } else {
706                    AuthType::None
707                }
708            }
709        };
710        Ok(Send {
711            id: send.id.map(SendId::new),
712            access_id: send.access_id,
713            name: require!(send.name).parse()?,
714            notes: EncString::try_from_optional(send.notes)?,
715            key: require!(send.key).parse()?,
716            password: send.password,
717            r#type: require!(send.r#type).try_into()?,
718            file: send.file.map(|f| (*f).try_into()).transpose()?,
719            text: send.text.map(|t| (*t).try_into()).transpose()?,
720            data: send.data.map(|d| (*d).try_into()).transpose()?,
721            max_access_count: send.max_access_count.map(|s| s as u32),
722            access_count: require!(send.access_count) as u32,
723            disabled: send.disabled.unwrap_or(false),
724            hide_email: send.hide_email.unwrap_or(false),
725            revision_date: require!(send.revision_date).parse()?,
726            deletion_date: require!(send.deletion_date).parse()?,
727            expiration_date: send.expiration_date.map(|s| s.parse()).transpose()?,
728            emails: send.emails,
729            auth_type,
730        })
731    }
732}
733
734impl TryFrom<bitwarden_api_api::models::SendType> for SendType {
735    type Error = bitwarden_core::MissingFieldError;
736
737    fn try_from(t: bitwarden_api_api::models::SendType) -> Result<Self, Self::Error> {
738        Ok(match t {
739            bitwarden_api_api::models::SendType::Text => SendType::Text,
740            bitwarden_api_api::models::SendType::File => SendType::File,
741            bitwarden_api_api::models::SendType::Item => SendType::Item,
742            bitwarden_api_api::models::SendType::__Unknown(_) => {
743                return Err(bitwarden_core::MissingFieldError("type"));
744            }
745        })
746    }
747}
748
749impl From<SendType> for bitwarden_api_api::models::SendType {
750    fn from(t: SendType) -> Self {
751        match t {
752            SendType::Text => bitwarden_api_api::models::SendType::Text,
753            SendType::File => bitwarden_api_api::models::SendType::File,
754            SendType::Item => bitwarden_api_api::models::SendType::Item,
755        }
756    }
757}
758
759impl TryFrom<bitwarden_api_api::models::AuthType> for AuthType {
760    type Error = bitwarden_core::MissingFieldError;
761
762    fn try_from(value: bitwarden_api_api::models::AuthType) -> Result<Self, Self::Error> {
763        Ok(match value {
764            bitwarden_api_api::models::AuthType::Email => AuthType::Email,
765            bitwarden_api_api::models::AuthType::Password => AuthType::Password,
766            bitwarden_api_api::models::AuthType::None => AuthType::None,
767            bitwarden_api_api::models::AuthType::__Unknown(_) => {
768                return Err(bitwarden_core::MissingFieldError("auth_type"));
769            }
770        })
771    }
772}
773
774impl From<AuthType> for bitwarden_api_api::models::AuthType {
775    fn from(value: AuthType) -> Self {
776        match value {
777            AuthType::Email => bitwarden_api_api::models::AuthType::Email,
778            AuthType::Password => bitwarden_api_api::models::AuthType::Password,
779            AuthType::None => bitwarden_api_api::models::AuthType::None,
780        }
781    }
782}
783
784impl From<SendFile> for SendFileModel {
785    fn from(file: SendFile) -> Self {
786        SendFileModel {
787            id: file.id,
788            file_name: Some(file.file_name.to_string()),
789            size: file.size,
790            size_name: file.size_name,
791        }
792    }
793}
794
795impl From<SendEncryptionType> for bitwarden_api_api::models::SendEncryptionType {
796    fn from(t: SendEncryptionType) -> Self {
797        match t {
798            SendEncryptionType::V1 => bitwarden_api_api::models::SendEncryptionType::V1,
799        }
800    }
801}
802
803impl TryFrom<bitwarden_api_api::models::SendEncryptionType> for SendEncryptionType {
804    type Error = bitwarden_core::MissingFieldError;
805
806    fn try_from(value: bitwarden_api_api::models::SendEncryptionType) -> Result<Self, Self::Error> {
807        Ok(match value {
808            bitwarden_api_api::models::SendEncryptionType::V1 => SendEncryptionType::V1,
809            bitwarden_api_api::models::SendEncryptionType::__Unknown(_) => {
810                return Err(bitwarden_core::MissingFieldError("encryption_version"));
811            }
812        })
813    }
814}
815
816impl From<SendText> for SendTextModel {
817    fn from(text: SendText) -> Self {
818        SendTextModel {
819            text: text.text.map(|text| text.to_string()),
820            hidden: Some(text.hidden),
821        }
822    }
823}
824
825impl TryFrom<SendFileModel> for SendFile {
826    type Error = SendParseError;
827
828    fn try_from(file: SendFileModel) -> Result<Self, Self::Error> {
829        Ok(SendFile {
830            id: file.id,
831            file_name: require!(file.file_name).parse()?,
832            size: file.size.map(|v| v.to_string()),
833            size_name: file.size_name,
834        })
835    }
836}
837
838impl TryFrom<SendTextModel> for SendText {
839    type Error = SendParseError;
840
841    fn try_from(text: SendTextModel) -> Result<Self, Self::Error> {
842        Ok(SendText {
843            text: EncString::try_from_optional(text.text)?,
844            hidden: text.hidden.unwrap_or(false),
845        })
846    }
847}
848
849impl TryFrom<SendDataModel> for SendItem {
850    type Error = SendParseError;
851
852    fn try_from(data: SendDataModel) -> Result<Self, Self::Error> {
853        let cipher = serde_json::from_str::<Cipher>(data.data.unwrap_or("{}".to_string()).as_str());
854        match cipher {
855            Err(_e) => Err(SendParseError::DeserializationFailure(
856                SendItemDeserializationFailureError,
857            )),
858            Ok(c) => Ok(SendItem {
859                encryption_version: SendEncryptionType::try_from(
860                    data.encryption_version
861                        .unwrap_or(DEFAULT_SEND_ENCRYPTION.into()),
862                )?,
863                data: c,
864            }),
865        }
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use bitwarden_core::key_management::create_test_crypto_with_user_key;
872    use bitwarden_crypto::SymmetricCryptoKey;
873
874    use super::*;
875
876    #[test]
877    fn test_get_send_key() {
878        // Initialize user encryption with some test data
879        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
880        let crypto = create_test_crypto_with_user_key(user_key);
881        let mut ctx = crypto.context();
882
883        let send_key = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g="
884            .parse()
885            .unwrap();
886
887        // Get the send key
888        let send_key = Send::get_key(&mut ctx, &send_key, SymmetricKeySlotId::User).unwrap();
889        #[allow(deprecated)]
890        let send_key = ctx.dangerous_get_symmetric_key(send_key).unwrap();
891        let send_key_b64 = send_key.to_base64();
892        assert_eq!(
893            send_key_b64.to_string(),
894            "IR9ImHGm6rRuIjiN7csj94bcZR5WYTJj5GtNfx33zm6tJCHUl+QZlpNPba8g2yn70KnOHsAODLcR0um6E3MAlg=="
895        );
896    }
897
898    #[test]
899    pub fn test_decrypt() {
900        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
901        let crypto = create_test_crypto_with_user_key(user_key);
902
903        let send = Send {
904            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
905            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
906            r#type: SendType::Text,
907            name: "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=".parse()
908                .unwrap(),
909            notes: None,
910            file: None,
911            text: Some(SendText {
912                text: "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=".parse().ok(),
913                hidden: false,
914            }),
915            data: None,
916            key: "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=".parse().unwrap(),
917            max_access_count: None,
918            access_count: 0,
919            password: None,
920            disabled: false,
921            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
922            expiration_date: None,
923            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
924            hide_email: false,
925            emails: None,
926            auth_type: AuthType::None,
927        };
928
929        let view: SendView = crypto.decrypt(&send).unwrap();
930
931        let expected = SendView {
932            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
933            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
934            name: "Test".to_string(),
935            notes: None,
936            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
937            new_password: None,
938            has_password: false,
939            r#type: SendType::Text,
940            file: None,
941            text: Some(SendTextView {
942                text: Some("This is a test".to_owned()),
943                hidden: false,
944            }),
945            data: None,
946            max_access_count: None,
947            access_count: 0,
948            disabled: false,
949            hide_email: false,
950            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
951            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
952            expiration_date: None,
953            emails: Vec::new(),
954            auth_type: AuthType::None,
955        };
956
957        assert_eq!(view, expected);
958    }
959
960    #[test]
961    pub fn test_encrypt() {
962        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
963        let crypto = create_test_crypto_with_user_key(user_key);
964
965        let view = SendView {
966            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
967            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
968            name: "Test".to_string(),
969            notes: None,
970            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
971            new_password: None,
972            has_password: false,
973            r#type: SendType::Text,
974            file: None,
975            text: Some(SendTextView {
976                text: Some("This is a test".to_owned()),
977                hidden: false,
978            }),
979            data: None,
980            max_access_count: None,
981            access_count: 0,
982            disabled: false,
983            hide_email: false,
984            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
985            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
986            expiration_date: None,
987            emails: Vec::new(),
988            auth_type: AuthType::None,
989        };
990
991        // Re-encrypt and decrypt again to ensure encrypt works
992        let v: SendView = crypto
993            .decrypt(&crypto.encrypt(view.clone()).unwrap())
994            .unwrap();
995        assert_eq!(v, view);
996    }
997
998    #[test]
999    pub fn test_create() {
1000        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
1001        let crypto = create_test_crypto_with_user_key(user_key);
1002
1003        let view = SendView {
1004            id: None,
1005            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
1006            name: "Test".to_string(),
1007            notes: None,
1008            key: None,
1009            new_password: None,
1010            has_password: false,
1011            r#type: SendType::Text,
1012            file: None,
1013            text: Some(SendTextView {
1014                text: Some("This is a test".to_owned()),
1015                hidden: false,
1016            }),
1017            data: None,
1018            max_access_count: None,
1019            access_count: 0,
1020            disabled: false,
1021            hide_email: false,
1022            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
1023            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
1024            expiration_date: None,
1025            emails: Vec::new(),
1026            auth_type: AuthType::None,
1027        };
1028
1029        // Re-encrypt and decrypt again to ensure encrypt works
1030        let v: SendView = crypto
1031            .decrypt(&crypto.encrypt(view.clone()).unwrap())
1032            .unwrap();
1033
1034        // Ignore key when comparing
1035        let t = SendView { key: None, ..v };
1036        assert_eq!(t, view);
1037    }
1038
1039    #[test]
1040    pub fn test_create_password() {
1041        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
1042        let crypto = create_test_crypto_with_user_key(user_key);
1043
1044        let view = SendView {
1045            id: None,
1046            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
1047            name: "Test".to_owned(),
1048            notes: None,
1049            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
1050            new_password: Some("abc123".to_owned()),
1051            has_password: false,
1052            r#type: SendType::Text,
1053            file: None,
1054            text: Some(SendTextView {
1055                text: Some("This is a test".to_owned()),
1056                hidden: false,
1057            }),
1058            data: None,
1059            max_access_count: None,
1060            access_count: 0,
1061            disabled: false,
1062            hide_email: false,
1063            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
1064            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
1065            expiration_date: None,
1066            emails: Vec::new(),
1067            auth_type: AuthType::Password,
1068        };
1069
1070        let send: Send = crypto.encrypt(view).unwrap();
1071
1072        assert_eq!(
1073            send.password,
1074            Some("vTIDfdj3FTDbejmMf+mJWpYdMXsxfeSd1Sma3sjCtiQ=".to_owned())
1075        );
1076        assert_eq!(send.auth_type, AuthType::Password);
1077
1078        let v: SendView = crypto.decrypt(&send).unwrap();
1079        assert_eq!(v.new_password, None);
1080        assert!(v.has_password);
1081        assert_eq!(v.auth_type, AuthType::Password);
1082    }
1083
1084    #[test]
1085    pub fn test_create_email_otp() {
1086        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
1087        let crypto = create_test_crypto_with_user_key(user_key);
1088
1089        let view = SendView {
1090            id: None,
1091            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
1092            name: "Test".to_owned(),
1093            notes: None,
1094            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
1095            new_password: None,
1096            has_password: false,
1097            r#type: SendType::Text,
1098            file: None,
1099            text: Some(SendTextView {
1100                text: Some("This is a test".to_owned()),
1101                hidden: false,
1102            }),
1103            data: None,
1104            max_access_count: None,
1105            access_count: 0,
1106            disabled: false,
1107            hide_email: false,
1108            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
1109            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
1110            expiration_date: None,
1111            emails: vec![
1112                String::from("[email protected]"),
1113                String::from("[email protected]"),
1114            ],
1115            auth_type: AuthType::Email,
1116        };
1117
1118        let send: Send = crypto.encrypt(view.clone()).unwrap();
1119
1120        // Verify decrypted view matches original prior to encrypting
1121        let v: SendView = crypto.decrypt(&send).unwrap();
1122
1123        assert_eq!(v, view);
1124    }
1125
1126    #[test]
1127    fn test_send_into_send_with_id_request_model() {
1128        let send_id = "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().unwrap();
1129        let revision_date = DateTime::parse_from_rfc3339("2024-01-07T23:56:48Z")
1130            .unwrap()
1131            .with_timezone(&Utc);
1132        let deletion_date = DateTime::parse_from_rfc3339("2024-01-14T23:56:48Z")
1133            .unwrap()
1134            .with_timezone(&Utc);
1135        let expiration_date = DateTime::parse_from_rfc3339("2024-01-20T23:56:48Z")
1136            .unwrap()
1137            .with_timezone(&Utc);
1138
1139        let name = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
1140        let notes = "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=";
1141        let key = "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=";
1142        let file_name = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g=";
1143        let text_value = "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=";
1144
1145        let send = Send {
1146            id: Some(SendId::new(send_id)),
1147            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_string()),
1148            name: name.parse().unwrap(),
1149            notes: Some(notes.parse().unwrap()),
1150            key: key.parse().unwrap(),
1151            password: Some("hash".to_string()),
1152            r#type: SendType::File,
1153            file: Some(SendFile {
1154                id: Some("file-id".to_string()),
1155                file_name: file_name.parse().unwrap(),
1156                size: Some("1234".to_string()),
1157                size_name: Some("1.2 KB".to_string()),
1158            }),
1159            text: Some(SendText {
1160                text: Some(text_value.parse().unwrap()),
1161                hidden: true,
1162            }),
1163            data: None,
1164            max_access_count: Some(42),
1165            access_count: 0,
1166            disabled: true,
1167            hide_email: true,
1168            revision_date,
1169            deletion_date,
1170            expiration_date: Some(expiration_date),
1171            emails: Some("[email protected],[email protected]".to_string()),
1172            auth_type: AuthType::Email,
1173        };
1174
1175        let model: SendWithIdRequestModel = send.into();
1176
1177        assert_eq!(model.id, send_id);
1178        assert_eq!(
1179            model.r#type,
1180            Some(bitwarden_api_api::models::SendType::File)
1181        );
1182        assert_eq!(
1183            model.auth_type,
1184            Some(bitwarden_api_api::models::AuthType::Email)
1185        );
1186        assert_eq!(model.file_length, Some(1234));
1187        assert_eq!(model.name.as_deref(), Some(name));
1188        assert_eq!(model.notes.as_deref(), Some(notes));
1189        assert_eq!(model.key, key);
1190        assert_eq!(model.max_access_count, Some(42));
1191        assert_eq!(
1192            model
1193                .expiration_date
1194                .unwrap()
1195                .parse::<DateTime<Utc>>()
1196                .unwrap(),
1197            expiration_date
1198        );
1199        assert_eq!(
1200            model.deletion_date.parse::<DateTime<Utc>>().unwrap(),
1201            deletion_date
1202        );
1203        assert_eq!(model.password.as_deref(), Some("hash"));
1204        assert_eq!(
1205            model.emails.as_deref(),
1206            Some("[email protected],[email protected]")
1207        );
1208        assert!(model.disabled);
1209        assert_eq!(model.hide_email, Some(true));
1210
1211        let file = model.file.unwrap();
1212        assert_eq!(file.id.as_deref(), Some("file-id"));
1213        assert_eq!(file.file_name.as_deref(), Some(file_name));
1214        assert_eq!(file.size.as_deref(), Some("1234"));
1215        assert_eq!(file.size_name.as_deref(), Some("1.2 KB"));
1216
1217        let text = model.text.unwrap();
1218        assert_eq!(text.text.as_deref(), Some(text_value));
1219        assert_eq!(text.hidden, Some(true));
1220    }
1221
1222    #[test]
1223    fn auth_data_hashed_password_returns_key_b64_verbatim() {
1224        // `HashedPassword` is the wire-form contract: the caller supplies the already-
1225        // derived base64 hash and the SDK must NOT run PBKDF2 again. We assert by
1226        // checking that the returned password equals the input byte-for-byte, including
1227        // for inputs that wouldn't be valid base64 (proves no decode/re-encode happens).
1228        let key_b64 = "pretend-this-is-a-pbkdf2-output==".to_string();
1229        let auth = SendAuthType::HashedPassword {
1230            key_b64: key_b64.clone(),
1231        };
1232
1233        let (password, emails) = auth.auth_data(b"any-send-key-bytes-here");
1234
1235        assert_eq!(password, Some(key_b64));
1236        assert_eq!(emails, None);
1237    }
1238
1239    #[test]
1240    fn auth_data_hashed_and_plaintext_diverge_for_same_input() {
1241        // Sanity check: passing the same string through `Password` and `HashedPassword`
1242        // produces different wire outputs, so a mis-routed caller (plaintext into
1243        // `HashedPassword`) fails loudly server-side rather than silently producing the
1244        // same hash as the plaintext path would.
1245        let same_string = "abc123".to_string();
1246        let send_key = b"send-key-salt-bytes";
1247
1248        let (plaintext_out, _) = SendAuthType::Password {
1249            password: same_string.clone(),
1250        }
1251        .auth_data(send_key);
1252        let (hashed_out, _) = SendAuthType::HashedPassword {
1253            key_b64: same_string,
1254        }
1255        .auth_data(send_key);
1256
1257        assert_ne!(
1258            plaintext_out, hashed_out,
1259            "Plaintext path must run PBKDF2; HashedPassword path must not"
1260        );
1261    }
1262
1263    #[test]
1264    fn auth_type_for_hashed_password_maps_to_password() {
1265        // Both `Password` and `HashedPassword` produce `authType = Password` on the wire;
1266        // the server doesn't distinguish.
1267        assert_eq!(
1268            SendAuthType::Password {
1269                password: "p".to_string()
1270            }
1271            .auth_type(),
1272            AuthType::Password,
1273        );
1274        assert_eq!(
1275            SendAuthType::HashedPassword {
1276                key_b64: "k".to_string()
1277            }
1278            .auth_type(),
1279            AuthType::Password,
1280        );
1281    }
1282
1283    /// Pins the wire shape of `SendAuthType` including the new `HashedPassword` variant
1284    /// (`"type": "hashedPassword"` under camelCase rename). Same pattern as the existing
1285    /// regression tests that pin internally-tagged serde enums.
1286    #[test]
1287    fn send_auth_type_round_trips_through_json() {
1288        let cases = [
1289            (SendAuthType::None, serde_json::json!({"type": "none"})),
1290            (
1291                SendAuthType::Password {
1292                    password: "hunter2".to_string(),
1293                },
1294                serde_json::json!({"type": "password", "password": "hunter2"}),
1295            ),
1296            (
1297                SendAuthType::HashedPassword {
1298                    key_b64: "deadbeef==".to_string(),
1299                },
1300                serde_json::json!({"type": "hashedPassword", "keyB64": "deadbeef=="}),
1301            ),
1302            (
1303                SendAuthType::Emails {
1304                    emails: vec!["[email protected]".to_string()],
1305                },
1306                serde_json::json!({"type": "emails", "emails": ["[email protected]"]}),
1307            ),
1308        ];
1309        for (value, expected) in cases {
1310            let serialized = serde_json::to_value(&value).expect("serialize");
1311            assert_eq!(serialized, expected, "wire shape mismatch for {value:?}");
1312            let deserialized: SendAuthType =
1313                serde_json::from_value(serialized).expect("round-trip");
1314            assert_eq!(deserialized, value, "round-trip mismatch for {value:?}");
1315        }
1316    }
1317
1318    #[test]
1319    fn typed_constructors_produce_expected_variants() {
1320        assert_eq!(
1321            SendAuthType::from_plaintext_password("hunter2".to_string()),
1322            SendAuthType::Password {
1323                password: "hunter2".to_string()
1324            },
1325        );
1326        assert_eq!(
1327            SendAuthType::from_hashed_password("deadbeef==".to_string()),
1328            SendAuthType::HashedPassword {
1329                key_b64: "deadbeef==".to_string()
1330            },
1331        );
1332    }
1333}