1use bitwarden_api_api::{
2 apis::ApiClient,
3 models::{self, SendEncryptionType},
4};
5use bitwarden_core::{ApiError, key_management::KeySlotIds};
6use bitwarden_crypto::{
7 CryptoError, Decryptable, EncString, KeyDecryptable as _, KeyStore, SymmetricCryptoKey,
8 derive_shareable_key,
9};
10use bitwarden_encoding::{B64, B64Url};
11use bitwarden_error::bitwarden_error;
12use bitwarden_vault::{Cipher, CipherView};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16#[cfg(feature = "wasm")]
17use tsify::Tsify;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20use zeroize::Zeroizing;
21
22use crate::{SendParseError, SendType, send::SEND_ITERATIONS, send_client::SendClient};
23
24pub(crate) const SEND_KEY_LEN: usize = 16;
28
29#[derive(Debug, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
37pub struct SendAccessResponse {
38 pub id: Option<String>,
40 #[serde(rename = "type")]
42 pub type_: Option<SendType>,
43 pub name: Option<String>,
45 pub text: Option<SendAccessTextResponse>,
47 pub file: Option<SendAccessFileResponse>,
49 pub data: Option<SendAccessItemResponse>,
51 pub expiration_date: Option<DateTime<Utc>>,
53 pub creator_identifier: Option<String>,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
61pub struct SendAccessTextResponse {
62 pub text: Option<String>,
64 pub hidden: bool,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
72pub struct SendAccessFileResponse {
73 pub id: Option<String>,
75 pub file_name: Option<String>,
77 pub size: Option<String>,
79 pub size_name: Option<String>,
81}
82
83#[derive(Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
87pub struct SendAccessItemResponse {
88 pub encryption_version: Option<SendEncryptionType>,
90 pub data: Option<String>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
98pub struct SendFileDownloadData {
99 pub id: Option<String>,
101 pub url: Option<String>,
103}
104
105#[derive(Debug, Serialize, Deserialize, PartialEq)]
120#[serde(rename_all = "camelCase")]
121#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
122pub struct SendAccessView {
123 pub id: Option<String>,
125 #[serde(rename = "type")]
127 pub type_: Option<SendType>,
128 pub name: Option<String>,
131 pub text: Option<SendAccessTextView>,
133 pub file: Option<SendAccessFileView>,
135 pub data: Option<SendAccessItemView>,
137 pub expiration_date: Option<DateTime<Utc>>,
139 pub creator_identifier: Option<String>,
141}
142
143#[derive(Debug, Serialize, Deserialize, PartialEq)]
145#[serde(rename_all = "camelCase")]
146#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
147pub struct SendAccessTextView {
148 pub text: Option<String>,
150 pub hidden: bool,
152}
153
154#[derive(Debug, Serialize, Deserialize, PartialEq)]
156#[serde(rename_all = "camelCase")]
157#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
158pub struct SendAccessFileView {
159 pub id: Option<String>,
161 pub file_name: Option<String>,
163 pub size: Option<String>,
165 pub size_name: Option<String>,
167}
168
169#[derive(Debug, Serialize, Deserialize, PartialEq)]
171#[serde(rename_all = "camelCase")]
172#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
173pub struct SendAccessItemView {
174 pub data: Option<CipherView>,
176}
177
178#[bitwarden_error(flat)]
183#[derive(Debug, Error)]
184pub enum SendAccessKeyError {
185 #[error("The send key is not valid url-safe base64")]
187 InvalidEncoding,
188 #[error("The send key must be {SEND_KEY_LEN} bytes")]
190 InvalidLength,
191}
192
193#[bitwarden_error(flat)]
196#[derive(Debug, Error)]
197pub enum SendAccessDecryptError {
198 #[error(transparent)]
201 Crypto(#[from] CryptoError),
202 #[error(transparent)]
204 Key(#[from] SendAccessKeyError),
205}
206
207pub struct SendAccessKey {
217 secret: Zeroizing<[u8; SEND_KEY_LEN]>,
221 key: SymmetricCryptoKey,
224}
225
226impl SendAccessKey {
227 pub fn from_url_b64(key_b64: &str) -> Result<Self, SendAccessKeyError> {
236 let decoded = Zeroizing::new(
239 B64Url::try_from(key_b64)
240 .map_err(|_| SendAccessKeyError::InvalidEncoding)?
241 .into_bytes(),
242 );
243 if decoded.len() != SEND_KEY_LEN {
244 return Err(SendAccessKeyError::InvalidLength);
245 }
246 let mut secret = Zeroizing::new([0u8; SEND_KEY_LEN]);
247 secret.copy_from_slice(&decoded);
248
249 let key = SymmetricCryptoKey::Aes256CbcHmacKey(derive_shareable_key(
250 secret.clone(),
251 "send",
252 Some("send"),
253 ));
254
255 Ok(Self { secret, key })
256 }
257
258 pub fn hash_password_b64(&self, password: &str) -> String {
265 let hashed =
266 bitwarden_crypto::pbkdf2(password.as_bytes(), self.secret.as_slice(), SEND_ITERATIONS);
267 B64::from(hashed.as_slice()).to_string()
268 }
269
270 pub fn decrypt_response(
273 &self,
274 response: SendAccessResponse,
275 ) -> Result<SendAccessView, SendAccessDecryptError> {
276 let text = match response.text {
277 Some(t) => Some(SendAccessTextView {
278 text: self.decrypt_optional(t.text)?,
279 hidden: t.hidden,
280 }),
281 None => None,
282 };
283 let file = match response.file {
284 Some(f) => Some(SendAccessFileView {
285 id: f.id,
286 file_name: self.decrypt_optional(f.file_name)?,
287 size: f.size,
288 size_name: f.size_name,
289 }),
290 None => None,
291 };
292 let data = match response.data {
293 Some(d) => {
294 let key_store: KeyStore<KeySlotIds> = KeyStore::default();
295 let mut ctx = key_store.context_mut();
296 let key = ctx.add_local_symmetric_key(self.key.clone());
297 let Some(data) = d.data else {
298 return Err(SendAccessDecryptError::Crypto(CryptoError::MissingField(
299 "data",
300 )));
301 };
302 let cipher = serde_json::from_str::<Cipher>(data.as_str());
303 match cipher {
304 Ok(c) => {
305 let cipher_view: CipherView = c.decrypt(&mut ctx, key)?;
306 Some(SendAccessItemView {
307 data: Some(cipher_view),
308 })
309 }
310 Err(_) => None,
311 }
312 }
313 None => None,
314 };
315
316 Ok(SendAccessView {
317 id: response.id,
318 type_: response.type_,
319 name: self.decrypt_optional(response.name)?,
320 text,
321 file,
322 data,
323 expiration_date: response.expiration_date,
324 creator_identifier: response.creator_identifier,
325 })
326 }
327
328 pub fn decrypt_file_buffer(&self, buffer: &[u8]) -> Result<Vec<u8>, SendAccessDecryptError> {
332 Ok(EncString::from_buffer(buffer)?.decrypt_with_key(&self.key)?)
333 }
334
335 fn decrypt_optional(
338 &self,
339 value: Option<String>,
340 ) -> Result<Option<String>, SendAccessDecryptError> {
341 match value {
342 Some(s) => Ok(Some(s.parse::<EncString>()?.decrypt_with_key(&self.key)?)),
343 None => Ok(None),
344 }
345 }
346}
347
348#[bitwarden_error(flat)]
350#[derive(Debug, Error)]
351pub enum AccessSendError {
352 #[error(transparent)]
354 Api(#[from] ApiError),
355 #[error(transparent)]
359 Parse(#[from] SendParseError),
360}
361
362#[bitwarden_error(flat)]
364#[derive(Debug, Error)]
365pub enum GetFileDownloadDataError {
366 #[error(transparent)]
368 Api(#[from] ApiError),
369}
370
371async fn access_send(
374 api_client: &ApiClient,
375 access_token: &str,
376) -> Result<SendAccessResponse, AccessSendError> {
377 let resp = api_client
378 .sends_api()
379 .access_using_auth(access_token)
380 .await?;
381 Ok(resp.try_into()?)
382}
383
384async fn get_file_download_data(
385 api_client: &ApiClient,
386 file_id: &str,
387 access_token: &str,
388) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
389 let resp = api_client
390 .sends_api()
391 .get_send_file_download_data_using_auth(file_id, access_token)
392 .await?;
393 Ok(resp.into())
394}
395
396impl TryFrom<models::SendAccessResponseModel> for SendAccessResponse {
399 type Error = SendParseError;
400
401 fn try_from(r: models::SendAccessResponseModel) -> Result<Self, Self::Error> {
402 Ok(SendAccessResponse {
403 id: r.id,
404 type_: r.r#type.map(SendType::try_from).transpose()?,
405 name: r.name,
406 text: r.text.map(|t| SendAccessTextResponse {
407 text: t.text,
408 hidden: t.hidden.unwrap_or(false),
409 }),
410 file: r.file.map(|f| SendAccessFileResponse {
411 id: f.id,
412 file_name: f.file_name,
413 size: f.size,
414 size_name: f.size_name,
415 }),
416 data: r.data.map(|dat| SendAccessItemResponse {
417 encryption_version: dat.encryption_version,
418 data: dat.data,
419 }),
420 expiration_date: r.expiration_date.map(|s| s.parse()).transpose()?,
421 creator_identifier: r.creator_identifier,
422 })
423 }
424}
425
426impl From<models::SendFileDownloadDataResponseModel> for SendFileDownloadData {
427 fn from(r: models::SendFileDownloadDataResponseModel) -> Self {
428 SendFileDownloadData {
429 id: r.id,
430 url: r.url,
431 }
432 }
433}
434
435#[cfg_attr(feature = "wasm", wasm_bindgen)]
438impl SendClient {
439 pub async fn access_send(
443 &self,
444 access_token: String,
445 ) -> Result<SendAccessResponse, AccessSendError> {
446 let config = self.client.internal.get_api_configurations();
447 access_send(&config.api_client, &access_token).await
448 }
449
450 pub async fn get_file_download_data(
452 &self,
453 access_token: String,
454 file_id: String,
455 ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
456 let config = self.client.internal.get_api_configurations();
457 get_file_download_data(&config.api_client, &file_id, &access_token).await
458 }
459
460 pub fn decrypt_send_access(
467 key_b64: String,
468 response: SendAccessResponse,
469 ) -> Result<SendAccessView, SendAccessDecryptError> {
470 let access_key = SendAccessKey::from_url_b64(key_b64.as_str())?;
471 access_key.decrypt_response(response)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use bitwarden_api_api::{
478 apis::ApiClient,
479 models::{
480 SendAccessResponseModel, SendFileDownloadDataResponseModel, SendFileModel,
481 SendTextModel, SendType,
482 },
483 };
484
485 use super::*;
486
487 const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
488 const FILE_ID: &str = "file-id-abc";
489 const ACCESS_TOKEN: &str = "send-access-token";
490
491 #[tokio::test]
494 async fn test_access_send_text() {
495 let api_client = ApiClient::new_mocked(|mock| {
496 mock.sends_api
497 .expect_access_using_auth()
498 .returning(|token| {
499 assert_eq!(token, ACCESS_TOKEN);
500 Ok(SendAccessResponseModel {
501 object: Some("send-access".to_string()),
502 id: Some(SEND_ID.to_string()),
503 r#type: Some(SendType::Text),
504 auth_type: None,
505 name: Some("encrypted-name".to_string()),
506 file: None,
507 text: Some(Box::new(SendTextModel {
508 text: Some("encrypted_send_text".to_string()),
509 hidden: Some(true),
510 })),
511 data: None,
512 expiration_date: Some("2025-01-10T00:00:00Z".to_string()),
513 creator_identifier: Some("[email protected]".to_string()),
514 })
515 })
516 .once();
517 });
518
519 let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
520
521 assert_eq!(result.id, Some(SEND_ID.to_string()));
522 assert_eq!(result.type_, Some(crate::SendType::Text));
523 assert_eq!(result.name, Some("encrypted-name".to_string()));
524 assert!(result.file.is_none());
525 let text = result.text.expect("text variant should be populated");
526 assert_eq!(text.text, Some("encrypted_send_text".to_string()));
527 assert!(text.hidden);
528 assert_eq!(
529 result.expiration_date,
530 Some("2025-01-10T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
531 );
532 assert_eq!(
533 result.creator_identifier,
534 Some("[email protected]".to_string())
535 );
536 }
537
538 #[tokio::test]
539 async fn test_access_send_file() {
540 let api_client = ApiClient::new_mocked(|mock| {
541 mock.sends_api
542 .expect_access_using_auth()
543 .returning(|token| {
544 assert_eq!(token, ACCESS_TOKEN);
545 Ok(SendAccessResponseModel {
546 object: Some("send-access".to_string()),
547 id: Some(SEND_ID.to_string()),
548 r#type: Some(SendType::File),
549 auth_type: None,
550 name: Some("encrypted-name".to_string()),
551 file: Some(Box::new(SendFileModel {
552 id: Some(FILE_ID.to_string()),
553 file_name: Some("encrypted-file-name".to_string()),
554 size: Some("4200".to_string()),
555 size_name: Some("4.2 KB".to_string()),
556 })),
557 text: None,
558 data: None,
559 expiration_date: None,
560 creator_identifier: None,
561 })
562 })
563 .once();
564 });
565
566 let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
567
568 assert_eq!(result.id, Some(SEND_ID.to_string()));
569 assert_eq!(result.type_, Some(crate::SendType::File));
570 assert_eq!(result.name, Some("encrypted-name".to_string()));
571 assert!(result.text.is_none());
572 let file = result.file.expect("file variant should be populated");
573 assert_eq!(file.id, Some(FILE_ID.to_string()));
574 assert_eq!(file.file_name, Some("encrypted-file-name".to_string()));
575 assert_eq!(file.size, Some("4200".to_string()));
576 assert_eq!(file.size_name, Some("4.2 KB".to_string()));
577 assert_eq!(result.expiration_date, None);
578 assert_eq!(result.creator_identifier, None);
579 }
580
581 #[tokio::test]
582 async fn test_access_send_http_error() {
583 let api_client = ApiClient::new_mocked(|mock| {
584 mock.sends_api
585 .expect_access_using_auth()
586 .returning(|_token| {
587 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
588 "Simulated error",
589 )))
590 })
591 .once();
592 });
593
594 let result = access_send(&api_client, ACCESS_TOKEN).await;
595
596 assert!(matches!(result.unwrap_err(), AccessSendError::Api(_)));
597 }
598
599 #[tokio::test]
602 async fn test_get_file_download_data() {
603 let api_client = ApiClient::new_mocked(|mock| {
604 mock.sends_api
605 .expect_get_send_file_download_data_using_auth()
606 .returning(|file_id, token| {
607 assert_eq!(file_id, FILE_ID);
608 assert_eq!(token, ACCESS_TOKEN);
609 Ok(SendFileDownloadDataResponseModel {
610 object: Some("send-fileDownload".to_string()),
611 id: Some(FILE_ID.to_string()),
612 url: Some("https://example.com/download".to_string()),
613 })
614 })
615 .once();
616 });
617
618 let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN)
619 .await
620 .unwrap();
621
622 assert_eq!(result.id, Some(FILE_ID.to_string()));
623 assert_eq!(result.url, Some("https://example.com/download".to_string()));
624 }
625
626 #[tokio::test]
627 async fn test_get_file_download_data_http_error() {
628 let api_client = ApiClient::new_mocked(|mock| {
629 mock.sends_api
630 .expect_get_send_file_download_data_using_auth()
631 .returning(|_file_id, _token| {
632 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
633 "Simulated error",
634 )))
635 })
636 .once();
637 });
638
639 let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN).await;
640
641 assert!(matches!(
642 result.unwrap_err(),
643 GetFileDownloadDataError::Api(_)
644 ));
645 }
646
647 mod send_access_key {
650 use bitwarden_core::key_management::create_test_crypto_with_user_key;
654 use bitwarden_crypto::{OctetStreamBytes, PrimitiveEncryptable as _, SymmetricCryptoKey};
655
656 use crate::{
657 Send, SendAccessDecryptError, SendAccessFileResponse, SendAccessKey,
658 SendAccessKeyError, SendAccessResponse, SendAccessTextResponse, SendAuthType,
659 SendClient, SendFileView, SendTextView, SendType, SendView,
660 };
661
662 const URL_KEY: &str = "Pgui0FK85cNhBGWHAlBHBw";
665 const USER_KEY: &str = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==";
666
667 fn user_key() -> SymmetricCryptoKey {
668 USER_KEY
669 .to_string()
670 .try_into()
671 .expect("valid test user key")
672 }
673
674 fn encrypt_send(view: SendView) -> Send {
678 create_test_crypto_with_user_key(user_key())
679 .encrypt(view)
680 .expect("send encrypts")
681 }
682
683 fn text_send_view(text: &str, name: &str) -> SendView {
684 SendView {
685 id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
686 access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
687 name: name.to_owned(),
688 notes: None,
689 key: Some(URL_KEY.to_owned()),
690 new_password: None,
691 has_password: false,
692 r#type: SendType::Text,
693 file: None,
694 text: Some(SendTextView {
695 text: Some(text.to_owned()),
696 hidden: false,
697 }),
698 data: None,
699 max_access_count: None,
700 access_count: 0,
701 disabled: false,
702 hide_email: false,
703 revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
704 deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
705 expiration_date: None,
706 emails: Vec::new(),
707 auth_type: crate::AuthType::None,
708 }
709 }
710
711 fn text_send_response(send: &Send) -> SendAccessResponse {
714 SendAccessResponse {
715 id: Some("access-id".to_owned()),
716 type_: Some(SendType::Text),
717 name: Some(send.name.to_string()),
718 text: Some(SendAccessTextResponse {
719 text: send
720 .text
721 .as_ref()
722 .and_then(|t| t.text.as_ref())
723 .map(|t| t.to_string()),
724 hidden: false,
725 }),
726 file: None,
727 data: None,
728 expiration_date: None,
729 creator_identifier: None,
730 }
731 }
732
733 #[test]
740 fn decrypts_ciphertext_produced_by_the_authenticated_path() {
741 let send = encrypt_send(text_send_view("This is a test", "Test"));
742 let response = text_send_response(&send);
743
744 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
745 let view = access_key.decrypt_response(response).expect("decrypts");
746
747 assert_eq!(view.name.as_deref(), Some("Test"));
748 assert_eq!(
749 view.text.expect("text present").text.as_deref(),
750 Some("This is a test")
751 );
752 }
753
754 #[test]
755 fn decrypts_file_name_produced_by_the_authenticated_path() {
756 let mut view = text_send_view("unused", "File Send");
757 view.r#type = SendType::File;
758 view.text = None;
759 view.file = Some(SendFileView {
760 id: Some("file-id".to_owned()),
761 file_name: "secrets.txt".to_owned(),
762 size: Some("11".to_owned()),
763 size_name: Some("11 B".to_owned()),
764 });
765 let send = encrypt_send(view);
766 let file = send.file.expect("file present");
767
768 let response = SendAccessResponse {
769 id: Some("access-id".to_owned()),
770 type_: Some(SendType::File),
771 name: Some(send.name.to_string()),
772 text: None,
773 file: Some(SendAccessFileResponse {
774 id: file.id.clone(),
775 file_name: Some(file.file_name.to_string()),
776 size: file.size.clone(),
777 size_name: file.size_name.clone(),
778 }),
779 data: None,
780 expiration_date: None,
781 creator_identifier: None,
782 };
783
784 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
785 let view = access_key.decrypt_response(response).expect("decrypts");
786
787 let decrypted_file = view.file.expect("file present");
788 assert_eq!(decrypted_file.file_name.as_deref(), Some("secrets.txt"));
789 assert_eq!(decrypted_file.size.as_deref(), Some("11"));
790 assert_eq!(decrypted_file.id.as_deref(), Some("file-id"));
791 assert_eq!(view.name.as_deref(), Some("File Send"));
792 }
793
794 #[test]
799 fn decrypt_file_buffer_round_trips_with_the_authenticated_path() {
800 let plaintext = b"file send contents".to_vec();
801
802 let crypto = create_test_crypto_with_user_key(user_key());
803 let mut ctx = crypto.context();
804 let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
805 .expect("url key decodes")
806 .into_bytes();
807 let send_key = Send::derive_shareable_key(&mut ctx, &raw_key).expect("key derives");
808 let encrypted = OctetStreamBytes::from(plaintext.clone())
809 .encrypt(&mut ctx, send_key)
810 .expect("buffer encrypts")
811 .to_buffer()
812 .expect("buffer serializes");
813
814 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
815 let decrypted = access_key
816 .decrypt_file_buffer(&encrypted)
817 .expect("buffer decrypts");
818
819 assert_eq!(decrypted, plaintext);
820 }
821
822 #[test]
827 fn hash_password_b64_matches_send_auth_type_auth_data() {
828 let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
829 .expect("url key decodes")
830 .into_bytes();
831
832 let (created_hash, emails) = SendAuthType::Password {
833 password: "hunter2".to_owned(),
834 }
835 .auth_data(&raw_key);
836 assert_eq!(emails, None);
837
838 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
839 let receive_hash = access_key.hash_password_b64("hunter2");
840
841 assert_eq!(
842 created_hash,
843 Some(receive_hash),
844 "receive's password hash must match the one `bw send create` stored"
845 );
846 }
847
848 #[test]
849 fn hash_password_b64_is_salted_with_the_send_key() {
850 let a = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
853 let b = SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
854 assert_ne!(
855 a.hash_password_b64("hunter2"),
856 b.hash_password_b64("hunter2")
857 );
858 }
859
860 #[test]
861 fn from_url_b64_accepts_padded_and_unpadded() {
862 let unpadded = SendAccessKey::from_url_b64(URL_KEY).expect("unpadded parses");
865 let padded =
866 SendAccessKey::from_url_b64(&format!("{URL_KEY}==")).expect("padded parses");
867 assert_eq!(
868 unpadded.hash_password_b64("p"),
869 padded.hash_password_b64("p"),
870 "padded and unpadded forms must derive the same key"
871 );
872 }
873
874 #[test]
875 fn from_url_b64_rejects_invalid_base64() {
876 assert!(matches!(
877 SendAccessKey::from_url_b64("not valid base64!"),
878 Err(SendAccessKeyError::InvalidEncoding)
879 ));
880 }
881
882 #[test]
883 fn from_url_b64_rejects_wrong_length() {
884 for bad in ["AAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"] {
887 assert!(
888 matches!(
889 SendAccessKey::from_url_b64(bad),
890 Err(SendAccessKeyError::InvalidLength)
891 ),
892 "expected InvalidLength for {bad:?}"
893 );
894 }
895 assert!(matches!(
896 SendAccessKey::from_url_b64(""),
897 Err(SendAccessKeyError::InvalidLength)
898 ));
899 }
900
901 #[test]
905 fn decrypt_response_tolerates_absent_fields() {
906 let response = SendAccessResponse {
907 id: None,
908 type_: None,
909 name: None,
910 text: Some(SendAccessTextResponse {
911 text: None,
912 hidden: true,
913 }),
914 file: None,
915 data: None,
916 expiration_date: None,
917 creator_identifier: None,
918 };
919
920 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
921 let view = access_key.decrypt_response(response).expect("decrypts");
922
923 assert_eq!(view.name, None);
924 assert_eq!(view.type_, None);
925 let text = view.text.expect("text block present");
926 assert_eq!(text.text, None);
927 assert!(text.hidden);
928 }
929
930 #[test]
931 fn decrypt_response_errors_on_a_key_that_does_not_match() {
932 let send = encrypt_send(text_send_view("This is a test", "Test"));
933 let response = SendAccessResponse {
934 id: None,
935 type_: Some(SendType::Text),
936 name: Some(send.name.to_string()),
937 text: None,
938 file: None,
939 data: None,
940 expiration_date: None,
941 creator_identifier: None,
942 };
943
944 let wrong_key =
946 SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
947 assert!(wrong_key.decrypt_response(response).is_err());
948 }
949
950 #[test]
951 fn decrypt_response_errors_on_a_malformed_enc_string() {
952 let response = SendAccessResponse {
953 id: None,
954 type_: Some(SendType::Text),
955 name: Some("this is not an EncString".to_owned()),
956 text: None,
957 file: None,
958 data: None,
959 expiration_date: None,
960 creator_identifier: None,
961 };
962
963 let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
964 assert!(access_key.decrypt_response(response).is_err());
965 }
966
967 #[test]
971 fn send_access_view_serializes_in_camel_case() {
972 let view = crate::SendAccessView {
973 id: Some("access-id".to_owned()),
974 type_: Some(SendType::File),
975 name: Some("name".to_owned()),
976 text: None,
977 file: Some(crate::SendAccessFileView {
978 id: Some("file-id".to_owned()),
979 file_name: Some("secrets.txt".to_owned()),
980 size: Some("11".to_owned()),
981 size_name: Some("11 B".to_owned()),
982 }),
983 data: None,
984 expiration_date: None,
985 creator_identifier: None,
986 };
987
988 let json = serde_json::to_value(&view).expect("serializes");
989 assert_eq!(json["type"], serde_json::json!(1));
990 assert_eq!(json["file"]["fileName"], serde_json::json!("secrets.txt"));
991 assert_eq!(json["file"]["sizeName"], serde_json::json!("11 B"));
992 assert_eq!(json["creatorIdentifier"], serde_json::Value::Null);
993 }
994
995 #[test]
996 fn decrypt_send_access_success() {
997 let send = encrypt_send(text_send_view("This is a test", "Test"));
998 let view =
999 SendClient::decrypt_send_access(URL_KEY.to_owned(), text_send_response(&send))
1000 .expect("decrypts");
1001
1002 assert_eq!(view.name.as_deref(), Some("Test"));
1003 assert_eq!(
1004 view.text.expect("text present").text.as_deref(),
1005 Some("This is a test")
1006 );
1007 }
1008
1009 #[test]
1010 fn decrypt_send_access_malformed_b64() {
1011 let response = SendAccessResponse {
1012 id: Some("access-id".to_owned()),
1013 type_: Some(SendType::Text),
1014 name: Some("Test".to_owned()),
1015 text: None,
1016 file: None,
1017 data: None,
1018 expiration_date: None,
1019 creator_identifier: None,
1020 };
1021
1022 let result = SendClient::decrypt_send_access("not valid base64!".to_owned(), response);
1023
1024 assert!(matches!(
1025 result.unwrap_err(),
1026 SendAccessDecryptError::Key(SendAccessKeyError::InvalidEncoding)
1027 ));
1028 }
1029 }
1030}