bitwarden_send/
send.rs

1use bitwarden_api_api::models::{SendFileModel, SendResponseModel, SendTextModel};
2use bitwarden_core::{
3    key_management::{KeyIds, SymmetricKeyId},
4    require,
5};
6use bitwarden_crypto::{
7    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
8    OctetStreamBytes, PrimitiveEncryptable, generate_random_bytes,
9};
10use bitwarden_encoding::{B64, B64Url};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_repr::{Deserialize_repr, Serialize_repr};
14use uuid::Uuid;
15use zeroize::Zeroizing;
16
17use crate::SendParseError;
18
19const SEND_ITERATIONS: u32 = 100_000;
20
21#[derive(Serialize, Deserialize, Debug)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
24pub struct SendFile {
25    pub id: Option<String>,
26    pub file_name: EncString,
27    pub size: Option<String>,
28    /// Readable size, ex: "4.2 KB" or "1.43 GB"
29    pub size_name: Option<String>,
30}
31
32#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
35pub struct SendFileView {
36    pub id: Option<String>,
37    pub file_name: String,
38    pub size: Option<String>,
39    /// Readable size, ex: "4.2 KB" or "1.43 GB"
40    pub size_name: Option<String>,
41}
42
43#[derive(Serialize, Deserialize, Debug)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
46pub struct SendText {
47    pub text: Option<EncString>,
48    pub hidden: bool,
49}
50
51#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
54pub struct SendTextView {
55    pub text: Option<String>,
56    pub hidden: bool,
57}
58
59#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
60#[repr(u8)]
61#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
62pub enum SendType {
63    Text = 0,
64    File = 1,
65}
66
67#[allow(missing_docs)]
68#[derive(Serialize, Deserialize, Debug)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
71pub struct Send {
72    pub id: Option<Uuid>,
73    pub access_id: Option<String>,
74
75    pub name: EncString,
76    pub notes: Option<EncString>,
77    pub key: EncString,
78    pub password: Option<String>,
79
80    pub r#type: SendType,
81    pub file: Option<SendFile>,
82    pub text: Option<SendText>,
83
84    pub max_access_count: Option<u32>,
85    pub access_count: u32,
86    pub disabled: bool,
87    pub hide_email: bool,
88
89    pub revision_date: DateTime<Utc>,
90    pub deletion_date: DateTime<Utc>,
91    pub expiration_date: Option<DateTime<Utc>>,
92}
93
94#[allow(missing_docs)]
95#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
98pub struct SendView {
99    pub id: Option<Uuid>,
100    pub access_id: Option<String>,
101
102    pub name: String,
103    pub notes: Option<String>,
104    /// Base64 encoded key
105    pub key: Option<String>,
106    /// Replace or add a password to an existing send. The SDK will always return None when
107    /// decrypting a [Send]
108    /// TODO: We should revisit this, one variant is to have `[Create, Update]SendView` DTOs.
109    pub new_password: Option<String>,
110    /// Denote if an existing send has a password. The SDK will ignore this value when creating or
111    /// updating sends.
112    pub has_password: bool,
113
114    pub r#type: SendType,
115    pub file: Option<SendFileView>,
116    pub text: Option<SendTextView>,
117
118    pub max_access_count: Option<u32>,
119    pub access_count: u32,
120    pub disabled: bool,
121    pub hide_email: bool,
122
123    pub revision_date: DateTime<Utc>,
124    pub deletion_date: DateTime<Utc>,
125    pub expiration_date: Option<DateTime<Utc>>,
126}
127
128#[allow(missing_docs)]
129#[derive(Serialize, Deserialize, Debug)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
132pub struct SendListView {
133    pub id: Option<Uuid>,
134    pub access_id: Option<String>,
135
136    pub name: String,
137
138    pub r#type: SendType,
139    pub disabled: bool,
140
141    pub revision_date: DateTime<Utc>,
142    pub deletion_date: DateTime<Utc>,
143    pub expiration_date: Option<DateTime<Utc>>,
144}
145
146const SEND_KEY: SymmetricKeyId = SymmetricKeyId::Local("send_key");
147
148impl Send {
149    #[allow(missing_docs)]
150    pub fn get_key(
151        ctx: &mut KeyStoreContext<KeyIds>,
152        send_key: &EncString,
153        enc_key: SymmetricKeyId,
154    ) -> Result<SymmetricKeyId, CryptoError> {
155        let key: Vec<u8> = send_key.decrypt(ctx, enc_key)?;
156        Self::derive_shareable_key(ctx, &key)
157    }
158
159    fn derive_shareable_key(
160        ctx: &mut KeyStoreContext<KeyIds>,
161        key: &[u8],
162    ) -> Result<SymmetricKeyId, CryptoError> {
163        let key = Zeroizing::new(key.try_into().map_err(|_| CryptoError::InvalidKeyLen)?);
164        ctx.derive_shareable_key(SEND_KEY, key, "send", Some("send"))
165    }
166}
167
168impl IdentifyKey<SymmetricKeyId> for Send {
169    fn key_identifier(&self) -> SymmetricKeyId {
170        SymmetricKeyId::User
171    }
172}
173
174impl IdentifyKey<SymmetricKeyId> for SendView {
175    fn key_identifier(&self) -> SymmetricKeyId {
176        SymmetricKeyId::User
177    }
178}
179
180impl Decryptable<KeyIds, SymmetricKeyId, SendTextView> for SendText {
181    fn decrypt(
182        &self,
183        ctx: &mut KeyStoreContext<KeyIds>,
184        key: SymmetricKeyId,
185    ) -> Result<SendTextView, CryptoError> {
186        Ok(SendTextView {
187            text: self.text.decrypt(ctx, key)?,
188            hidden: self.hidden,
189        })
190    }
191}
192
193impl CompositeEncryptable<KeyIds, SymmetricKeyId, SendText> for SendTextView {
194    fn encrypt_composite(
195        &self,
196        ctx: &mut KeyStoreContext<KeyIds>,
197        key: SymmetricKeyId,
198    ) -> Result<SendText, CryptoError> {
199        Ok(SendText {
200            text: self.text.encrypt(ctx, key)?,
201            hidden: self.hidden,
202        })
203    }
204}
205
206impl Decryptable<KeyIds, SymmetricKeyId, SendFileView> for SendFile {
207    fn decrypt(
208        &self,
209        ctx: &mut KeyStoreContext<KeyIds>,
210        key: SymmetricKeyId,
211    ) -> Result<SendFileView, CryptoError> {
212        Ok(SendFileView {
213            id: self.id.clone(),
214            file_name: self.file_name.decrypt(ctx, key)?,
215            size: self.size.clone(),
216            size_name: self.size_name.clone(),
217        })
218    }
219}
220
221impl CompositeEncryptable<KeyIds, SymmetricKeyId, SendFile> for SendFileView {
222    fn encrypt_composite(
223        &self,
224        ctx: &mut KeyStoreContext<KeyIds>,
225        key: SymmetricKeyId,
226    ) -> Result<SendFile, CryptoError> {
227        Ok(SendFile {
228            id: self.id.clone(),
229            file_name: self.file_name.encrypt(ctx, key)?,
230            size: self.size.clone(),
231            size_name: self.size_name.clone(),
232        })
233    }
234}
235
236impl Decryptable<KeyIds, SymmetricKeyId, SendView> for Send {
237    fn decrypt(
238        &self,
239        ctx: &mut KeyStoreContext<KeyIds>,
240        key: SymmetricKeyId,
241    ) -> Result<SendView, CryptoError> {
242        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
243        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
244        // the stretched key
245        let k: Vec<u8> = self.key.decrypt(ctx, key)?;
246        let key = Send::derive_shareable_key(ctx, &k)?;
247
248        Ok(SendView {
249            id: self.id,
250            access_id: self.access_id.clone(),
251
252            name: self.name.decrypt(ctx, key).ok().unwrap_or_default(),
253            notes: self.notes.decrypt(ctx, key).ok().flatten(),
254            key: Some(B64Url::from(k).to_string()),
255            new_password: None,
256            has_password: self.password.is_some(),
257
258            r#type: self.r#type,
259            file: self.file.decrypt(ctx, key).ok().flatten(),
260            text: self.text.decrypt(ctx, key).ok().flatten(),
261
262            max_access_count: self.max_access_count,
263            access_count: self.access_count,
264            disabled: self.disabled,
265            hide_email: self.hide_email,
266
267            revision_date: self.revision_date,
268            deletion_date: self.deletion_date,
269            expiration_date: self.expiration_date,
270        })
271    }
272}
273
274impl Decryptable<KeyIds, SymmetricKeyId, SendListView> for Send {
275    fn decrypt(
276        &self,
277        ctx: &mut KeyStoreContext<KeyIds>,
278        key: SymmetricKeyId,
279    ) -> Result<SendListView, CryptoError> {
280        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
281        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
282        // the stretched key
283        let key = Send::get_key(ctx, &self.key, key)?;
284
285        Ok(SendListView {
286            id: self.id,
287            access_id: self.access_id.clone(),
288
289            name: self.name.decrypt(ctx, key)?,
290            r#type: self.r#type,
291
292            disabled: self.disabled,
293
294            revision_date: self.revision_date,
295            deletion_date: self.deletion_date,
296            expiration_date: self.expiration_date,
297        })
298    }
299}
300
301impl CompositeEncryptable<KeyIds, SymmetricKeyId, Send> for SendView {
302    fn encrypt_composite(
303        &self,
304        ctx: &mut KeyStoreContext<KeyIds>,
305        key: SymmetricKeyId,
306    ) -> Result<Send, CryptoError> {
307        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
308        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
309        // the stretched key
310        let k = match (&self.key, &self.id) {
311            // Existing send, decrypt key
312            (Some(k), _) => B64Url::try_from(k.as_str())
313                .map_err(|_| CryptoError::InvalidKey)?
314                .as_bytes()
315                .to_vec(),
316            // New send, generate random key
317            (None, None) => {
318                let key = generate_random_bytes::<[u8; 16]>();
319                key.to_vec()
320            }
321            // Existing send without key
322            _ => return Err(CryptoError::InvalidKey),
323        };
324        let send_key = Send::derive_shareable_key(ctx, &k)?;
325
326        Ok(Send {
327            id: self.id,
328            access_id: self.access_id.clone(),
329
330            name: self.name.encrypt(ctx, send_key)?,
331            notes: self.notes.encrypt(ctx, send_key)?,
332            key: OctetStreamBytes::from(k.clone()).encrypt(ctx, key)?,
333            password: self.new_password.as_ref().map(|password| {
334                let password = bitwarden_crypto::pbkdf2(password.as_bytes(), &k, SEND_ITERATIONS);
335                B64::from(password.as_slice()).to_string()
336            }),
337
338            r#type: self.r#type,
339            file: self.file.encrypt_composite(ctx, send_key)?,
340            text: self.text.encrypt_composite(ctx, send_key)?,
341
342            max_access_count: self.max_access_count,
343            access_count: self.access_count,
344            disabled: self.disabled,
345            hide_email: self.hide_email,
346
347            revision_date: self.revision_date,
348            deletion_date: self.deletion_date,
349            expiration_date: self.expiration_date,
350        })
351    }
352}
353
354impl TryFrom<SendResponseModel> for Send {
355    type Error = SendParseError;
356
357    fn try_from(send: SendResponseModel) -> Result<Self, Self::Error> {
358        Ok(Send {
359            id: send.id,
360            access_id: send.access_id,
361            name: require!(send.name).parse()?,
362            notes: EncString::try_from_optional(send.notes)?,
363            key: require!(send.key).parse()?,
364            password: send.password,
365            r#type: require!(send.r#type).into(),
366            file: send.file.map(|f| (*f).try_into()).transpose()?,
367            text: send.text.map(|t| (*t).try_into()).transpose()?,
368            max_access_count: send.max_access_count.map(|s| s as u32),
369            access_count: require!(send.access_count) as u32,
370            disabled: send.disabled.unwrap_or(false),
371            hide_email: send.hide_email.unwrap_or(false),
372            revision_date: require!(send.revision_date).parse()?,
373            deletion_date: require!(send.deletion_date).parse()?,
374            expiration_date: send.expiration_date.map(|s| s.parse()).transpose()?,
375        })
376    }
377}
378
379impl From<bitwarden_api_api::models::SendType> for SendType {
380    fn from(t: bitwarden_api_api::models::SendType) -> Self {
381        match t {
382            bitwarden_api_api::models::SendType::Text => SendType::Text,
383            bitwarden_api_api::models::SendType::File => SendType::File,
384        }
385    }
386}
387
388impl TryFrom<SendFileModel> for SendFile {
389    type Error = SendParseError;
390
391    fn try_from(file: SendFileModel) -> Result<Self, Self::Error> {
392        Ok(SendFile {
393            id: file.id,
394            file_name: require!(file.file_name).parse()?,
395            size: file.size.map(|v| v.to_string()),
396            size_name: file.size_name,
397        })
398    }
399}
400
401impl TryFrom<SendTextModel> for SendText {
402    type Error = SendParseError;
403
404    fn try_from(text: SendTextModel) -> Result<Self, Self::Error> {
405        Ok(SendText {
406            text: EncString::try_from_optional(text.text)?,
407            hidden: text.hidden.unwrap_or(false),
408        })
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use bitwarden_core::key_management::create_test_crypto_with_user_key;
415    use bitwarden_crypto::SymmetricCryptoKey;
416
417    use super::*;
418
419    #[test]
420    fn test_get_send_key() {
421        // Initialize user encryption with some test data
422        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
423        let crypto = create_test_crypto_with_user_key(user_key);
424        let mut ctx = crypto.context();
425
426        let send_key = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g="
427            .parse()
428            .unwrap();
429
430        // Get the send key
431        let send_key = Send::get_key(&mut ctx, &send_key, SymmetricKeyId::User).unwrap();
432        #[allow(deprecated)]
433        let send_key = ctx.dangerous_get_symmetric_key(send_key).unwrap();
434        let send_key_b64 = send_key.to_base64();
435        assert_eq!(
436            send_key_b64.to_string(),
437            "IR9ImHGm6rRuIjiN7csj94bcZR5WYTJj5GtNfx33zm6tJCHUl+QZlpNPba8g2yn70KnOHsAODLcR0um6E3MAlg=="
438        );
439    }
440
441    #[test]
442    pub fn test_decrypt() {
443        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
444        let crypto = create_test_crypto_with_user_key(user_key);
445
446        let send = Send {
447            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
448            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
449            r#type: SendType::Text,
450            name: "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=".parse()
451                .unwrap(),
452            notes: None,
453            file: None,
454            text: Some(SendText {
455                text: "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=".parse().ok(),
456                hidden: false,
457            }),
458            key: "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=".parse().unwrap(),
459            max_access_count: None,
460            access_count: 0,
461            password: None,
462            disabled: false,
463            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
464            expiration_date: None,
465            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
466            hide_email: false,
467        };
468
469        let view: SendView = crypto.decrypt(&send).unwrap();
470
471        let expected = SendView {
472            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
473            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
474            name: "Test".to_string(),
475            notes: None,
476            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
477            new_password: None,
478            has_password: false,
479            r#type: SendType::Text,
480            file: None,
481            text: Some(SendTextView {
482                text: Some("This is a test".to_owned()),
483                hidden: false,
484            }),
485            max_access_count: None,
486            access_count: 0,
487            disabled: false,
488            hide_email: false,
489            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
490            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
491            expiration_date: None,
492        };
493
494        assert_eq!(view, expected);
495    }
496
497    #[test]
498    pub fn test_encrypt() {
499        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
500        let crypto = create_test_crypto_with_user_key(user_key);
501
502        let view = SendView {
503            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
504            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
505            name: "Test".to_string(),
506            notes: None,
507            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
508            new_password: None,
509            has_password: false,
510            r#type: SendType::Text,
511            file: None,
512            text: Some(SendTextView {
513                text: Some("This is a test".to_owned()),
514                hidden: false,
515            }),
516            max_access_count: None,
517            access_count: 0,
518            disabled: false,
519            hide_email: false,
520            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
521            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
522            expiration_date: None,
523        };
524
525        // Re-encrypt and decrypt again to ensure encrypt works
526        let v: SendView = crypto
527            .decrypt(&crypto.encrypt(view.clone()).unwrap())
528            .unwrap();
529        assert_eq!(v, view);
530    }
531
532    #[test]
533    pub fn test_create() {
534        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
535        let crypto = create_test_crypto_with_user_key(user_key);
536
537        let view = SendView {
538            id: None,
539            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
540            name: "Test".to_string(),
541            notes: None,
542            key: None,
543            new_password: None,
544            has_password: false,
545            r#type: SendType::Text,
546            file: None,
547            text: Some(SendTextView {
548                text: Some("This is a test".to_owned()),
549                hidden: false,
550            }),
551            max_access_count: None,
552            access_count: 0,
553            disabled: false,
554            hide_email: false,
555            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
556            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
557            expiration_date: None,
558        };
559
560        // Re-encrypt and decrypt again to ensure encrypt works
561        let v: SendView = crypto
562            .decrypt(&crypto.encrypt(view.clone()).unwrap())
563            .unwrap();
564
565        // Ignore key when comparing
566        let t = SendView { key: None, ..v };
567        assert_eq!(t, view);
568    }
569
570    #[test]
571    pub fn test_create_password() {
572        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
573        let crypto = create_test_crypto_with_user_key(user_key);
574
575        let view = SendView {
576            id: None,
577            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
578            name: "Test".to_owned(),
579            notes: None,
580            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
581            new_password: Some("abc123".to_owned()),
582            has_password: false,
583            r#type: SendType::Text,
584            file: None,
585            text: Some(SendTextView {
586                text: Some("This is a test".to_owned()),
587                hidden: false,
588            }),
589            max_access_count: None,
590            access_count: 0,
591            disabled: false,
592            hide_email: false,
593            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
594            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
595            expiration_date: None,
596        };
597
598        let send: Send = crypto.encrypt(view).unwrap();
599
600        assert_eq!(
601            send.password,
602            Some("vTIDfdj3FTDbejmMf+mJWpYdMXsxfeSd1Sma3sjCtiQ=".to_owned())
603        );
604
605        let v: SendView = crypto.decrypt(&send).unwrap();
606        assert_eq!(v.new_password, None);
607        assert!(v.has_password);
608    }
609}