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
146impl Send {
147    #[allow(missing_docs)]
148    pub fn get_key(
149        ctx: &mut KeyStoreContext<KeyIds>,
150        send_key: &EncString,
151        enc_key: SymmetricKeyId,
152    ) -> Result<SymmetricKeyId, CryptoError> {
153        let key: Vec<u8> = send_key.decrypt(ctx, enc_key)?;
154        Self::derive_shareable_key(ctx, &key)
155    }
156
157    fn derive_shareable_key(
158        ctx: &mut KeyStoreContext<KeyIds>,
159        key: &[u8],
160    ) -> Result<SymmetricKeyId, CryptoError> {
161        let key = Zeroizing::new(key.try_into().map_err(|_| CryptoError::InvalidKeyLen)?);
162        ctx.derive_shareable_key(key, "send", Some("send"))
163    }
164}
165
166impl IdentifyKey<SymmetricKeyId> for Send {
167    fn key_identifier(&self) -> SymmetricKeyId {
168        SymmetricKeyId::User
169    }
170}
171
172impl IdentifyKey<SymmetricKeyId> for SendView {
173    fn key_identifier(&self) -> SymmetricKeyId {
174        SymmetricKeyId::User
175    }
176}
177
178impl Decryptable<KeyIds, SymmetricKeyId, SendTextView> for SendText {
179    fn decrypt(
180        &self,
181        ctx: &mut KeyStoreContext<KeyIds>,
182        key: SymmetricKeyId,
183    ) -> Result<SendTextView, CryptoError> {
184        Ok(SendTextView {
185            text: self.text.decrypt(ctx, key)?,
186            hidden: self.hidden,
187        })
188    }
189}
190
191impl CompositeEncryptable<KeyIds, SymmetricKeyId, SendText> for SendTextView {
192    fn encrypt_composite(
193        &self,
194        ctx: &mut KeyStoreContext<KeyIds>,
195        key: SymmetricKeyId,
196    ) -> Result<SendText, CryptoError> {
197        Ok(SendText {
198            text: self.text.encrypt(ctx, key)?,
199            hidden: self.hidden,
200        })
201    }
202}
203
204impl Decryptable<KeyIds, SymmetricKeyId, SendFileView> for SendFile {
205    fn decrypt(
206        &self,
207        ctx: &mut KeyStoreContext<KeyIds>,
208        key: SymmetricKeyId,
209    ) -> Result<SendFileView, CryptoError> {
210        Ok(SendFileView {
211            id: self.id.clone(),
212            file_name: self.file_name.decrypt(ctx, key)?,
213            size: self.size.clone(),
214            size_name: self.size_name.clone(),
215        })
216    }
217}
218
219impl CompositeEncryptable<KeyIds, SymmetricKeyId, SendFile> for SendFileView {
220    fn encrypt_composite(
221        &self,
222        ctx: &mut KeyStoreContext<KeyIds>,
223        key: SymmetricKeyId,
224    ) -> Result<SendFile, CryptoError> {
225        Ok(SendFile {
226            id: self.id.clone(),
227            file_name: self.file_name.encrypt(ctx, key)?,
228            size: self.size.clone(),
229            size_name: self.size_name.clone(),
230        })
231    }
232}
233
234impl Decryptable<KeyIds, SymmetricKeyId, SendView> for Send {
235    fn decrypt(
236        &self,
237        ctx: &mut KeyStoreContext<KeyIds>,
238        key: SymmetricKeyId,
239    ) -> Result<SendView, CryptoError> {
240        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
241        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
242        // the stretched key
243        let k: Vec<u8> = self.key.decrypt(ctx, key)?;
244        let key = Send::derive_shareable_key(ctx, &k)?;
245
246        Ok(SendView {
247            id: self.id,
248            access_id: self.access_id.clone(),
249
250            name: self.name.decrypt(ctx, key).ok().unwrap_or_default(),
251            notes: self.notes.decrypt(ctx, key).ok().flatten(),
252            key: Some(B64Url::from(k).to_string()),
253            new_password: None,
254            has_password: self.password.is_some(),
255
256            r#type: self.r#type,
257            file: self.file.decrypt(ctx, key).ok().flatten(),
258            text: self.text.decrypt(ctx, key).ok().flatten(),
259
260            max_access_count: self.max_access_count,
261            access_count: self.access_count,
262            disabled: self.disabled,
263            hide_email: self.hide_email,
264
265            revision_date: self.revision_date,
266            deletion_date: self.deletion_date,
267            expiration_date: self.expiration_date,
268        })
269    }
270}
271
272impl Decryptable<KeyIds, SymmetricKeyId, SendListView> for Send {
273    fn decrypt(
274        &self,
275        ctx: &mut KeyStoreContext<KeyIds>,
276        key: SymmetricKeyId,
277    ) -> Result<SendListView, CryptoError> {
278        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
279        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
280        // the stretched key
281        let key = Send::get_key(ctx, &self.key, key)?;
282
283        Ok(SendListView {
284            id: self.id,
285            access_id: self.access_id.clone(),
286
287            name: self.name.decrypt(ctx, key)?,
288            r#type: self.r#type,
289
290            disabled: self.disabled,
291
292            revision_date: self.revision_date,
293            deletion_date: self.deletion_date,
294            expiration_date: self.expiration_date,
295        })
296    }
297}
298
299impl CompositeEncryptable<KeyIds, SymmetricKeyId, Send> for SendView {
300    fn encrypt_composite(
301        &self,
302        ctx: &mut KeyStoreContext<KeyIds>,
303        key: SymmetricKeyId,
304    ) -> Result<Send, CryptoError> {
305        // For sends, we first decrypt the send key with the user key, and stretch it to it's full
306        // size For the rest of the fields, we ignore the provided SymmetricCryptoKey and
307        // the stretched key
308        let k = match (&self.key, &self.id) {
309            // Existing send, decrypt key
310            (Some(k), _) => B64Url::try_from(k.as_str())
311                .map_err(|_| CryptoError::InvalidKey)?
312                .as_bytes()
313                .to_vec(),
314            // New send, generate random key
315            (None, None) => {
316                let key = generate_random_bytes::<[u8; 16]>();
317                key.to_vec()
318            }
319            // Existing send without key
320            _ => return Err(CryptoError::InvalidKey),
321        };
322        let send_key = Send::derive_shareable_key(ctx, &k)?;
323
324        Ok(Send {
325            id: self.id,
326            access_id: self.access_id.clone(),
327
328            name: self.name.encrypt(ctx, send_key)?,
329            notes: self.notes.encrypt(ctx, send_key)?,
330            key: OctetStreamBytes::from(k.clone()).encrypt(ctx, key)?,
331            password: self.new_password.as_ref().map(|password| {
332                let password = bitwarden_crypto::pbkdf2(password.as_bytes(), &k, SEND_ITERATIONS);
333                B64::from(password.as_slice()).to_string()
334            }),
335
336            r#type: self.r#type,
337            file: self.file.encrypt_composite(ctx, send_key)?,
338            text: self.text.encrypt_composite(ctx, send_key)?,
339
340            max_access_count: self.max_access_count,
341            access_count: self.access_count,
342            disabled: self.disabled,
343            hide_email: self.hide_email,
344
345            revision_date: self.revision_date,
346            deletion_date: self.deletion_date,
347            expiration_date: self.expiration_date,
348        })
349    }
350}
351
352impl TryFrom<SendResponseModel> for Send {
353    type Error = SendParseError;
354
355    fn try_from(send: SendResponseModel) -> Result<Self, Self::Error> {
356        Ok(Send {
357            id: send.id,
358            access_id: send.access_id,
359            name: require!(send.name).parse()?,
360            notes: EncString::try_from_optional(send.notes)?,
361            key: require!(send.key).parse()?,
362            password: send.password,
363            r#type: require!(send.r#type).into(),
364            file: send.file.map(|f| (*f).try_into()).transpose()?,
365            text: send.text.map(|t| (*t).try_into()).transpose()?,
366            max_access_count: send.max_access_count.map(|s| s as u32),
367            access_count: require!(send.access_count) as u32,
368            disabled: send.disabled.unwrap_or(false),
369            hide_email: send.hide_email.unwrap_or(false),
370            revision_date: require!(send.revision_date).parse()?,
371            deletion_date: require!(send.deletion_date).parse()?,
372            expiration_date: send.expiration_date.map(|s| s.parse()).transpose()?,
373        })
374    }
375}
376
377impl From<bitwarden_api_api::models::SendType> for SendType {
378    fn from(t: bitwarden_api_api::models::SendType) -> Self {
379        match t {
380            bitwarden_api_api::models::SendType::Text => SendType::Text,
381            bitwarden_api_api::models::SendType::File => SendType::File,
382        }
383    }
384}
385
386impl TryFrom<SendFileModel> for SendFile {
387    type Error = SendParseError;
388
389    fn try_from(file: SendFileModel) -> Result<Self, Self::Error> {
390        Ok(SendFile {
391            id: file.id,
392            file_name: require!(file.file_name).parse()?,
393            size: file.size.map(|v| v.to_string()),
394            size_name: file.size_name,
395        })
396    }
397}
398
399impl TryFrom<SendTextModel> for SendText {
400    type Error = SendParseError;
401
402    fn try_from(text: SendTextModel) -> Result<Self, Self::Error> {
403        Ok(SendText {
404            text: EncString::try_from_optional(text.text)?,
405            hidden: text.hidden.unwrap_or(false),
406        })
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use bitwarden_core::key_management::create_test_crypto_with_user_key;
413    use bitwarden_crypto::SymmetricCryptoKey;
414
415    use super::*;
416
417    #[test]
418    fn test_get_send_key() {
419        // Initialize user encryption with some test data
420        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
421        let crypto = create_test_crypto_with_user_key(user_key);
422        let mut ctx = crypto.context();
423
424        let send_key = "2.+1KUfOX8A83Xkwk1bumo/w==|Nczvv+DTkeP466cP/wMDnGK6W9zEIg5iHLhcuQG6s+M=|SZGsfuIAIaGZ7/kzygaVUau3LeOvJUlolENBOU+LX7g="
425            .parse()
426            .unwrap();
427
428        // Get the send key
429        let send_key = Send::get_key(&mut ctx, &send_key, SymmetricKeyId::User).unwrap();
430        #[allow(deprecated)]
431        let send_key = ctx.dangerous_get_symmetric_key(send_key).unwrap();
432        let send_key_b64 = send_key.to_base64();
433        assert_eq!(
434            send_key_b64.to_string(),
435            "IR9ImHGm6rRuIjiN7csj94bcZR5WYTJj5GtNfx33zm6tJCHUl+QZlpNPba8g2yn70KnOHsAODLcR0um6E3MAlg=="
436        );
437    }
438
439    #[test]
440    pub fn test_decrypt() {
441        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
442        let crypto = create_test_crypto_with_user_key(user_key);
443
444        let send = Send {
445            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
446            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
447            r#type: SendType::Text,
448            name: "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=".parse()
449                .unwrap(),
450            notes: None,
451            file: None,
452            text: Some(SendText {
453                text: "2.2VPyLzk1tMLug0X3x7RkaQ==|mrMt9vbZsCJhJIj4eebKyg==|aZ7JeyndytEMR1+uEBupEvaZuUE69D/ejhfdJL8oKq0=".parse().ok(),
454                hidden: false,
455            }),
456            key: "2.KLv/j0V4Ebs0dwyPdtt4vw==|jcrFuNYN1Qb3onBlwvtxUV/KpdnR1LPRL4EsCoXNAt4=|gHSywGy4Rj/RsCIZFwze4s2AACYKBtqDXTrQXjkgtIE=".parse().unwrap(),
457            max_access_count: None,
458            access_count: 0,
459            password: None,
460            disabled: false,
461            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
462            expiration_date: None,
463            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
464            hide_email: false,
465        };
466
467        let view: SendView = crypto.decrypt(&send).unwrap();
468
469        let expected = SendView {
470            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
471            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
472            name: "Test".to_string(),
473            notes: None,
474            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
475            new_password: None,
476            has_password: false,
477            r#type: SendType::Text,
478            file: None,
479            text: Some(SendTextView {
480                text: Some("This is a test".to_owned()),
481                hidden: false,
482            }),
483            max_access_count: None,
484            access_count: 0,
485            disabled: false,
486            hide_email: false,
487            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
488            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
489            expiration_date: None,
490        };
491
492        assert_eq!(view, expected);
493    }
494
495    #[test]
496    pub fn test_encrypt() {
497        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
498        let crypto = create_test_crypto_with_user_key(user_key);
499
500        let view = SendView {
501            id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
502            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
503            name: "Test".to_string(),
504            notes: None,
505            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
506            new_password: None,
507            has_password: false,
508            r#type: SendType::Text,
509            file: None,
510            text: Some(SendTextView {
511                text: Some("This is a test".to_owned()),
512                hidden: false,
513            }),
514            max_access_count: None,
515            access_count: 0,
516            disabled: false,
517            hide_email: false,
518            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
519            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
520            expiration_date: None,
521        };
522
523        // Re-encrypt and decrypt again to ensure encrypt works
524        let v: SendView = crypto
525            .decrypt(&crypto.encrypt(view.clone()).unwrap())
526            .unwrap();
527        assert_eq!(v, view);
528    }
529
530    #[test]
531    pub fn test_create() {
532        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
533        let crypto = create_test_crypto_with_user_key(user_key);
534
535        let view = SendView {
536            id: None,
537            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
538            name: "Test".to_string(),
539            notes: None,
540            key: None,
541            new_password: None,
542            has_password: false,
543            r#type: SendType::Text,
544            file: None,
545            text: Some(SendTextView {
546                text: Some("This is a test".to_owned()),
547                hidden: false,
548            }),
549            max_access_count: None,
550            access_count: 0,
551            disabled: false,
552            hide_email: false,
553            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
554            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
555            expiration_date: None,
556        };
557
558        // Re-encrypt and decrypt again to ensure encrypt works
559        let v: SendView = crypto
560            .decrypt(&crypto.encrypt(view.clone()).unwrap())
561            .unwrap();
562
563        // Ignore key when comparing
564        let t = SendView { key: None, ..v };
565        assert_eq!(t, view);
566    }
567
568    #[test]
569    pub fn test_create_password() {
570        let user_key: SymmetricCryptoKey = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==".to_string().try_into().unwrap();
571        let crypto = create_test_crypto_with_user_key(user_key);
572
573        let view = SendView {
574            id: None,
575            access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
576            name: "Test".to_owned(),
577            notes: None,
578            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
579            new_password: Some("abc123".to_owned()),
580            has_password: false,
581            r#type: SendType::Text,
582            file: None,
583            text: Some(SendTextView {
584                text: Some("This is a test".to_owned()),
585                hidden: false,
586            }),
587            max_access_count: None,
588            access_count: 0,
589            disabled: false,
590            hide_email: false,
591            revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
592            deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
593            expiration_date: None,
594        };
595
596        let send: Send = crypto.encrypt(view).unwrap();
597
598        assert_eq!(
599            send.password,
600            Some("vTIDfdj3FTDbejmMf+mJWpYdMXsxfeSd1Sma3sjCtiQ=".to_owned())
601        );
602
603        let v: SendView = crypto.decrypt(&send).unwrap();
604        assert_eq!(v.new_password, None);
605        assert!(v.has_password);
606    }
607}