Skip to main content

bitwarden_send/
send.rs

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