Skip to main content

bitwarden_send/
access.rs

1use bitwarden_api_api::{
2    apis::ApiClient,
3    models::{self, SendEncryptionType},
4};
5use bitwarden_core::{ApiError, key_management::KeySlotIds};
6use bitwarden_crypto::{
7    CryptoError, EncString, KeyDecryptable as _, KeyStore, SymmetricCryptoKey, derive_shareable_key,
8};
9use bitwarden_encoding::{B64, B64Url};
10use bitwarden_error::bitwarden_error;
11use bitwarden_vault::{CipherId, CipherView};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19use zeroize::Zeroizing;
20
21use crate::{
22    SendParseError, SendType,
23    send::{SEND_ITERATIONS, SendItemMetadata},
24    send_client::SendClient,
25};
26
27/// Length in bytes of the raw Send key carried in a Send URL fragment. `pub(crate)` so
28/// `SendView::encrypt_composite` (`send.rs`) can generate keys of exactly this length,
29/// enforcing the relationship at compile time instead of relying on a test to catch drift.
30pub(crate) const SEND_KEY_LEN: usize = 16;
31
32// ===== Public output types (returned to callers) =====
33
34/// View of a send's accessible content, returned after a successful send access call.
35/// Name, text, file, and item fields are encrypted and must be decrypted client-side
36/// using the key derived from the URL fragment.
37#[derive(Debug, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
40pub struct SendAccessResponse {
41    /// The send access ID
42    pub id: Option<String>,
43    /// The send type.
44    #[serde(rename = "type")]
45    pub type_: Option<SendType>,
46    /// Encrypted send name
47    pub name: Option<String>,
48    /// Text content (if type is Text)
49    pub text: Option<SendAccessTextResponse>,
50    /// File metadata (if type is File)
51    pub file: Option<SendAccessFileResponse>,
52    /// Item metadata (if type is Item)
53    pub data: Option<SendAccessItemResponse>,
54    /// When the send expires.
55    pub expiration_date: Option<DateTime<Utc>>,
56    /// The creator's identifier (email), if not hidden
57    pub creator_identifier: Option<String>,
58}
59
60/// Encrypted text content of a text send.
61#[derive(Debug, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
64pub struct SendAccessTextResponse {
65    /// Encrypted text content
66    pub text: Option<String>,
67    /// Whether to hide the text by default
68    pub hidden: bool,
69}
70
71/// Encrypted file metadata of a file send.
72#[derive(Debug, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
75pub struct SendAccessFileResponse {
76    /// The file ID
77    pub id: Option<String>,
78    /// Encrypted file name
79    pub file_name: Option<String>,
80    /// File size in bytes as a string
81    pub size: Option<String>,
82    /// Human-readable size (e.g. "4.2 KB")
83    pub size_name: Option<String>,
84}
85
86/// Encrypted item metadata of an item send.
87#[derive(Debug, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
90pub struct SendAccessItemResponse {
91    /// The version of encryption used to encrypt the item data
92    pub encryption_version: Option<SendEncryptionType>,
93    /// The encrypted item data
94    pub data: Option<String>,
95    /// Unencrypted item metadata
96    pub metadata: SendItemMetadata,
97}
98
99/// File download URL data returned from a send file access call.
100#[derive(Debug, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
103pub struct SendFileDownloadData {
104    /// The file ID
105    pub id: Option<String>,
106    /// The pre-signed download URL
107    pub url: Option<String>,
108}
109
110// ===== Decrypted views of an anonymous send access =====
111
112/// Plaintext view of a [`SendAccessResponse`], produced by
113/// [`SendAccessKey::decrypt_response`].
114///
115/// Mirrors the legacy CLI's `SendAccessResponse` output shape (`apps/cli`) so that a
116/// JSON dump of a received send stays recognizable to existing scripts, with two additions:
117/// `expirationDate` and `creatorIdentifier` are already present on the raw
118/// `SendAccessResponse` the server returns, but the legacy CLI's output shape drops them.
119///
120/// `text`/`file` are kept as independent `Option`s rather than collapsed into an enum with
121/// associated data: `type_` is `Option<SendType>` on the wire and an unrecognized or absent
122/// discriminant must still round-trip (both the legacy CLI and `bw receive` fall back to
123/// dumping whatever the server returned), which a total enum could not represent.
124#[derive(Debug, Serialize, Deserialize, PartialEq)]
125#[serde(rename_all = "camelCase")]
126#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
127pub struct SendAccessView {
128    /// The send access ID
129    pub id: Option<String>,
130    /// The send type.
131    #[serde(rename = "type")]
132    pub type_: Option<SendType>,
133    /// The decrypted send name. `None` when the send has no name, which is not an error —
134    /// a nameless text send still has printable content.
135    pub name: Option<String>,
136    /// Decrypted text content (if type is Text)
137    pub text: Option<SendAccessTextView>,
138    /// Decrypted file metadata (if type is File)
139    pub file: Option<SendAccessFileView>,
140    /// Decrypted item content (if type is Item)
141    pub data: Option<SendAccessItemView>,
142    /// When the send expires.
143    pub expiration_date: Option<DateTime<Utc>>,
144    /// The creator's identifier (email), if not hidden
145    pub creator_identifier: Option<String>,
146}
147
148/// Decrypted text content of a text send.
149#[derive(Debug, Serialize, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
152pub struct SendAccessTextView {
153    /// The decrypted text content
154    pub text: Option<String>,
155    /// Whether to hide the text by default
156    pub hidden: bool,
157}
158
159/// Decrypted file metadata of a file send.
160#[derive(Debug, Serialize, Deserialize, PartialEq)]
161#[serde(rename_all = "camelCase")]
162#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
163pub struct SendAccessFileView {
164    /// The file ID
165    pub id: Option<String>,
166    /// The decrypted file name
167    pub file_name: Option<String>,
168    /// File size in bytes as a string
169    pub size: Option<String>,
170    /// Human-readable size (e.g. "4.2 KB")
171    pub size_name: Option<String>,
172}
173
174/// Decrypted item metadata of an item send.
175#[derive(Debug, Serialize, Deserialize, PartialEq)]
176#[serde(rename_all = "camelCase")]
177#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
178pub struct SendAccessItemView {
179    /// The decrypted Cipher data
180    pub data: Option<CipherView>,
181}
182
183// ===== Error types =====
184
185/// Error returned when the key from a send URL fragment cannot be turned into a
186/// [`SendAccessKey`]. Deliberately carries no key material.
187#[bitwarden_error(flat)]
188#[derive(Debug, Error)]
189pub enum SendAccessKeyError {
190    /// The fragment key was not valid URL-safe base64.
191    #[error("The send key is not valid url-safe base64")]
192    InvalidEncoding,
193    /// The decoded key was not `SEND_KEY_LEN` (16) bytes long.
194    #[error("The send key must be {SEND_KEY_LEN} bytes")]
195    InvalidLength,
196}
197
198/// Error returned when decrypting an anonymous send access response or file blob fails.
199/// Wraps [`CryptoError`], which never embeds plaintext or key material in its messages.
200#[bitwarden_error(flat)]
201#[derive(Debug, Error)]
202pub enum SendAccessDecryptError {
203    /// The ciphertext was malformed, or the key derived from the URL fragment does not
204    /// decrypt it (wrong key, or a tampered response).
205    #[error(transparent)]
206    Crypto(#[from] CryptoError),
207    /// The key could not be derived from the URL fragment
208    #[error(transparent)]
209    Key(#[from] SendAccessKeyError),
210}
211
212// ===== Anonymous access key =====
213
214/// Symmetric key material for an anonymous Send access, derived entirely from the URL
215/// fragment — no account key store or logged-in user involved. This is the only Send flow
216/// whose key doesn't come from the user's key store, hence a standalone type rather than a
217/// [`bitwarden_crypto::KeyStoreContext`] slot.
218///
219/// The key is opaque by design: callers need to *use* it three ways (hash a password,
220/// decrypt a response, decrypt a downloaded blob) but never need to *see* it.
221pub struct SendAccessKey {
222    /// The raw fragment key, exactly as decoded from the URL — not run through the KDF that
223    /// derives [`Self::key`] below. Retained because the send password hash is salted with
224    /// these raw bytes, not with the derived key.
225    secret: Zeroizing<[u8; SEND_KEY_LEN]>,
226    /// The send's actual symmetric key, derived from `secret` via `derive_shareable_key`.
227    /// This is what encrypts the send's fields and file blob.
228    key: SymmetricCryptoKey,
229}
230
231impl SendAccessKey {
232    /// Parse the URL-safe-base64 key from a Send URL fragment and stretch it into the
233    /// send's symmetric key. Equivalent to the legacy clients' `Utils.fromUrlB64ToArray`
234    /// followed by `KeyService.makeSendKey`.
235    ///
236    /// The `"send"`/`Some("send")` name/info pair must stay in lockstep with
237    /// `Send::derive_shareable_key` — that is what `bw send create` used to encrypt the send,
238    /// so any divergence makes every send undecryptable through this path. The round-trip
239    /// test below pins the two together.
240    pub fn from_url_b64(key_b64: &str) -> Result<Self, SendAccessKeyError> {
241        // Wrap the decoded bytes in `Zeroizing` before any length check so a wrong-length
242        // key is still scrubbed rather than left in freed memory.
243        let decoded = Zeroizing::new(
244            B64Url::try_from(key_b64)
245                .map_err(|_| SendAccessKeyError::InvalidEncoding)?
246                .into_bytes(),
247        );
248        if decoded.len() != SEND_KEY_LEN {
249            return Err(SendAccessKeyError::InvalidLength);
250        }
251        let mut secret = Zeroizing::new([0u8; SEND_KEY_LEN]);
252        secret.copy_from_slice(&decoded);
253
254        let key = SymmetricCryptoKey::Aes256CbcHmacKey(derive_shareable_key(
255            secret.clone(),
256            "send",
257            Some("send"),
258        ));
259
260        Ok(Self { secret, key })
261    }
262
263    /// PBKDF2-HMAC-SHA256 over `password`, `SEND_ITERATIONS` (100,000) rounds, salted with the
264    /// raw URL key — the `password_hash_b64` credential the send-access token grant expects.
265    ///
266    /// Identical recipe to `SendAuthType::auth_data`, which is what `bw send create --password`
267    /// stored on the server. A pinning test asserts the two agree; if they ever diverge, every
268    /// password-protected receive fails with an opaque server-side rejection.
269    pub fn hash_password_b64(&self, password: &str) -> String {
270        let hashed =
271            bitwarden_crypto::pbkdf2(password.as_bytes(), self.secret.as_slice(), SEND_ITERATIONS);
272        B64::from(hashed.as_slice()).to_string()
273    }
274
275    /// Decrypt a [`SendAccessResponse`]'s encrypted fields into a plaintext
276    /// [`SendAccessView`].
277    pub fn decrypt_response(
278        &self,
279        response: SendAccessResponse,
280    ) -> Result<SendAccessView, SendAccessDecryptError> {
281        let text = match response.text {
282            Some(t) => Some(SendAccessTextView {
283                text: self.decrypt_optional(t.text)?,
284                hidden: t.hidden,
285            }),
286            None => None,
287        };
288        let file = match response.file {
289            Some(f) => Some(SendAccessFileView {
290                id: f.id,
291                file_name: self.decrypt_optional(f.file_name)?,
292                size: f.size,
293                size_name: f.size_name,
294            }),
295            None => None,
296        };
297        let data = match response.data {
298            Some(d) => {
299                let key_store: KeyStore<KeySlotIds> = KeyStore::default();
300                let mut ctx = key_store.context_mut();
301                let key = ctx.add_local_symmetric_key(self.key.clone());
302                let Some(data) = d.data else {
303                    return Err(SendAccessDecryptError::Crypto(CryptoError::MissingField(
304                        "data",
305                    )));
306                };
307                let mut cipher_view = CipherView::unseal_blob_for_item_sends(&data, &mut ctx, key)?;
308                // The blob holds no id; restore it from the metadata.
309                cipher_view.id = Some(d.metadata.item_id);
310                Some(SendAccessItemView {
311                    data: Some(cipher_view),
312                })
313            }
314            None => None,
315        };
316
317        Ok(SendAccessView {
318            id: response.id,
319            type_: response.type_,
320            name: self.decrypt_optional(response.name)?,
321            text,
322            file,
323            data,
324            expiration_date: response.expiration_date,
325            creator_identifier: response.creator_identifier,
326        })
327    }
328
329    /// Decrypt a downloaded file-send blob. The blob is a single whole-buffer [`EncString`]
330    /// (not the chunked attachment format), matching the legacy clients'
331    /// `EncArrayBuffer.fromResponse` + `EncryptService.decryptFileData`.
332    pub fn decrypt_file_buffer(&self, buffer: &[u8]) -> Result<Vec<u8>, SendAccessDecryptError> {
333        Ok(EncString::from_buffer(buffer)?.decrypt_with_key(&self.key)?)
334    }
335
336    /// Parse and decrypt an optional wire-format [`EncString`] field. Absent fields stay
337    /// absent — the caller decides whether a missing field is fatal.
338    fn decrypt_optional(
339        &self,
340        value: Option<String>,
341    ) -> Result<Option<String>, SendAccessDecryptError> {
342        match value {
343            Some(s) => Ok(Some(s.parse::<EncString>()?.decrypt_with_key(&self.key)?)),
344            None => Ok(None),
345        }
346    }
347}
348
349/// Error returned when accessing a send fails.
350#[bitwarden_error(flat)]
351#[derive(Debug, Error)]
352pub enum AccessSendError {
353    /// An API or network error occurred.
354    #[error(transparent)]
355    Api(#[from] ApiError),
356    /// The response body could not be parsed into a [`SendAccessResponse`] — either a
357    /// required field was missing, the send type was an unrecognized value, or a date
358    /// field was malformed.
359    #[error(transparent)]
360    Parse(#[from] SendParseError),
361}
362
363/// Error returned when getting send file download data fails.
364#[bitwarden_error(flat)]
365#[derive(Debug, Error)]
366pub enum GetFileDownloadDataError {
367    /// An API or network error occurred.
368    #[error(transparent)]
369    Api(#[from] ApiError),
370}
371
372// ===== HTTP request functions =====
373
374async fn access_send(
375    api_client: &ApiClient,
376    access_token: &str,
377) -> Result<SendAccessResponse, AccessSendError> {
378    let resp = api_client
379        .sends_api()
380        .access_using_auth(access_token)
381        .await?;
382    Ok(resp.try_into()?)
383}
384
385async fn get_file_download_data(
386    api_client: &ApiClient,
387    file_id: &str,
388    access_token: &str,
389) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
390    let resp = api_client
391        .sends_api()
392        .get_send_file_download_data_using_auth(file_id, access_token)
393        .await?;
394    Ok(resp.into())
395}
396
397// ===== Conversions from API response models =====
398
399impl TryFrom<models::SendAccessResponseModel> for SendAccessResponse {
400    type Error = SendParseError;
401
402    fn try_from(r: models::SendAccessResponseModel) -> Result<Self, Self::Error> {
403        Ok(SendAccessResponse {
404            id: r.id,
405            type_: r.r#type.map(SendType::try_from).transpose()?,
406            name: r.name,
407            text: r.text.map(|t| SendAccessTextResponse {
408                text: t.text,
409                hidden: t.hidden.unwrap_or(false),
410            }),
411            file: r.file.map(|f| SendAccessFileResponse {
412                id: f.id,
413                file_name: f.file_name,
414                size: f.size,
415                size_name: f.size_name,
416            }),
417            data: r.data.map(|dat| SendAccessItemResponse {
418                encryption_version: dat.encryption_version,
419                data: dat.data,
420                metadata: SendItemMetadata {
421                    item_id: CipherId::new(dat.metadata.item_id),
422                },
423            }),
424            expiration_date: r.expiration_date.map(|s| s.parse()).transpose()?,
425            creator_identifier: r.creator_identifier,
426        })
427    }
428}
429
430impl From<models::SendFileDownloadDataResponseModel> for SendFileDownloadData {
431    fn from(r: models::SendFileDownloadDataResponseModel) -> Self {
432        SendFileDownloadData {
433            id: r.id,
434            url: r.url,
435        }
436    }
437}
438
439// ===== SendClient methods =====
440
441#[cfg_attr(feature = "wasm", wasm_bindgen)]
442impl SendClient {
443    /// Accesses a send, authenticated with a send access token.
444    /// The returned [SendAccessResponse] contains encrypted fields that must be decrypted
445    /// client-side using the key derived from the URL fragment.
446    pub async fn access_send(
447        &self,
448        access_token: String,
449    ) -> Result<SendAccessResponse, AccessSendError> {
450        let config = self.client.internal.get_api_configurations();
451        access_send(&config.api_client, &access_token).await
452    }
453
454    /// Gets file download data for a file send, authenticated with a send access token.
455    pub async fn get_file_download_data(
456        &self,
457        access_token: String,
458        file_id: String,
459    ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
460        let config = self.client.internal.get_api_configurations();
461        get_file_download_data(&config.api_client, &file_id, &access_token).await
462    }
463
464    /// Decrypt a [`SendAccessResponse`] into a [`SendAccessView`].
465    ///
466    /// `key_b64` is the URL-safe-base64 send key from the trailing segment of the send URL
467    /// fragment (16 bytes when decoded) — the same form [`SendAccessKey::from_url_b64`] accepts
468    ///
469    /// This is a temporary function to support the transition to fully using the SDK for Send logic
470    pub fn decrypt_send_access(
471        key_b64: String,
472        response: SendAccessResponse,
473    ) -> Result<SendAccessView, SendAccessDecryptError> {
474        let access_key = SendAccessKey::from_url_b64(key_b64.as_str())?;
475        access_key.decrypt_response(response)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use bitwarden_api_api::{
482        apis::ApiClient,
483        models::{
484            SendAccessResponseModel, SendFileDownloadDataResponseModel, SendFileModel,
485            SendTextModel, SendType,
486        },
487    };
488
489    use super::*;
490
491    const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
492    const FILE_ID: &str = "file-id-abc";
493    const ACCESS_TOKEN: &str = "send-access-token";
494
495    // ===== access_send =====
496
497    #[tokio::test]
498    async fn test_access_send_text() {
499        let api_client = ApiClient::new_mocked(|mock| {
500            mock.sends_api
501                .expect_access_using_auth()
502                .returning(|token| {
503                    assert_eq!(token, ACCESS_TOKEN);
504                    Ok(SendAccessResponseModel {
505                        object: Some("send-access".to_string()),
506                        id: Some(SEND_ID.to_string()),
507                        r#type: Some(SendType::Text),
508                        auth_type: None,
509                        name: Some("encrypted-name".to_string()),
510                        file: None,
511                        text: Some(Box::new(SendTextModel {
512                            text: Some("encrypted_send_text".to_string()),
513                            hidden: Some(true),
514                        })),
515                        data: None,
516                        expiration_date: Some("2025-01-10T00:00:00Z".to_string()),
517                        creator_identifier: Some("[email protected]".to_string()),
518                    })
519                })
520                .once();
521        });
522
523        let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
524
525        assert_eq!(result.id, Some(SEND_ID.to_string()));
526        assert_eq!(result.type_, Some(crate::SendType::Text));
527        assert_eq!(result.name, Some("encrypted-name".to_string()));
528        assert!(result.file.is_none());
529        let text = result.text.expect("text variant should be populated");
530        assert_eq!(text.text, Some("encrypted_send_text".to_string()));
531        assert!(text.hidden);
532        assert_eq!(
533            result.expiration_date,
534            Some("2025-01-10T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
535        );
536        assert_eq!(
537            result.creator_identifier,
538            Some("[email protected]".to_string())
539        );
540    }
541
542    #[tokio::test]
543    async fn test_access_send_file() {
544        let api_client = ApiClient::new_mocked(|mock| {
545            mock.sends_api
546                .expect_access_using_auth()
547                .returning(|token| {
548                    assert_eq!(token, ACCESS_TOKEN);
549                    Ok(SendAccessResponseModel {
550                        object: Some("send-access".to_string()),
551                        id: Some(SEND_ID.to_string()),
552                        r#type: Some(SendType::File),
553                        auth_type: None,
554                        name: Some("encrypted-name".to_string()),
555                        file: Some(Box::new(SendFileModel {
556                            id: Some(FILE_ID.to_string()),
557                            file_name: Some("encrypted-file-name".to_string()),
558                            size: Some("4200".to_string()),
559                            size_name: Some("4.2 KB".to_string()),
560                        })),
561                        text: None,
562                        data: None,
563                        expiration_date: None,
564                        creator_identifier: None,
565                    })
566                })
567                .once();
568        });
569
570        let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
571
572        assert_eq!(result.id, Some(SEND_ID.to_string()));
573        assert_eq!(result.type_, Some(crate::SendType::File));
574        assert_eq!(result.name, Some("encrypted-name".to_string()));
575        assert!(result.text.is_none());
576        let file = result.file.expect("file variant should be populated");
577        assert_eq!(file.id, Some(FILE_ID.to_string()));
578        assert_eq!(file.file_name, Some("encrypted-file-name".to_string()));
579        assert_eq!(file.size, Some("4200".to_string()));
580        assert_eq!(file.size_name, Some("4.2 KB".to_string()));
581        assert_eq!(result.expiration_date, None);
582        assert_eq!(result.creator_identifier, None);
583    }
584
585    #[tokio::test]
586    async fn test_access_send_http_error() {
587        let api_client = ApiClient::new_mocked(|mock| {
588            mock.sends_api
589                .expect_access_using_auth()
590                .returning(|_token| {
591                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
592                        "Simulated error",
593                    )))
594                })
595                .once();
596        });
597
598        let result = access_send(&api_client, ACCESS_TOKEN).await;
599
600        assert!(matches!(result.unwrap_err(), AccessSendError::Api(_)));
601    }
602
603    // ===== get_file_download_data =====
604
605    #[tokio::test]
606    async fn test_get_file_download_data() {
607        let api_client = ApiClient::new_mocked(|mock| {
608            mock.sends_api
609                .expect_get_send_file_download_data_using_auth()
610                .returning(|file_id, token| {
611                    assert_eq!(file_id, FILE_ID);
612                    assert_eq!(token, ACCESS_TOKEN);
613                    Ok(SendFileDownloadDataResponseModel {
614                        object: Some("send-fileDownload".to_string()),
615                        id: Some(FILE_ID.to_string()),
616                        url: Some("https://example.com/download".to_string()),
617                    })
618                })
619                .once();
620        });
621
622        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN)
623            .await
624            .unwrap();
625
626        assert_eq!(result.id, Some(FILE_ID.to_string()));
627        assert_eq!(result.url, Some("https://example.com/download".to_string()));
628    }
629
630    #[tokio::test]
631    async fn test_get_file_download_data_http_error() {
632        let api_client = ApiClient::new_mocked(|mock| {
633            mock.sends_api
634                .expect_get_send_file_download_data_using_auth()
635                .returning(|_file_id, _token| {
636                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
637                        "Simulated error",
638                    )))
639                })
640                .once();
641        });
642
643        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN).await;
644
645        assert!(matches!(
646            result.unwrap_err(),
647            GetFileDownloadDataError::Api(_)
648        ));
649    }
650
651    // ===== SendAccessKey =====
652
653    mod send_access_key {
654        //! Tests for [`SendAccessKey`]: URL-fragment key parsing/derivation, password hashing,
655        //! and decrypting a [`SendAccessResponse`] into a [`SendAccessView`].
656
657        use bitwarden_core::key_management::create_test_crypto_with_user_key;
658        use bitwarden_crypto::{OctetStreamBytes, PrimitiveEncryptable as _, SymmetricCryptoKey};
659        use bitwarden_vault::CipherId;
660
661        use crate::{
662            Send, SendAccessDecryptError, SendAccessFileResponse, SendAccessKey,
663            SendAccessKeyError, SendAccessResponse, SendAccessTextResponse, SendAuthType,
664            SendClient, SendFileView, SendTextView, SendType, SendView,
665            access::SendAccessItemResponse,
666            send::{
667                SendItemMetadata,
668                tests::{TEST_ITEM_ID, TEST_VECTOR_ITEM_SEND_DATA},
669            },
670        };
671
672        /// The url-safe-base64 form of a 16-byte send key, as it appears in the trailing
673        /// segment of a send URL fragment. Shared with the tests in `send.rs`.
674        const URL_KEY: &str = "Pgui0FK85cNhBGWHAlBHBw";
675        const USER_KEY: &str = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==";
676
677        fn user_key() -> SymmetricCryptoKey {
678            USER_KEY
679                .to_string()
680                .try_into()
681                .expect("valid test user key")
682        }
683
684        /// Encrypt a [`SendView`] through the authenticated (key-store) path — the exact
685        /// path `bw send create` takes — so the receive path can be checked against real
686        /// ciphertext rather than a fixture that could drift.
687        fn encrypt_send(view: SendView) -> Send {
688            create_test_crypto_with_user_key(user_key())
689                .encrypt(view)
690                .expect("send encrypts")
691        }
692
693        fn text_send_view(text: &str, name: &str) -> SendView {
694            SendView {
695                id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
696                access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
697                name: name.to_owned(),
698                notes: None,
699                key: Some(URL_KEY.to_owned()),
700                new_password: None,
701                has_password: false,
702                r#type: SendType::Text,
703                file: None,
704                text: Some(SendTextView {
705                    text: Some(text.to_owned()),
706                    hidden: false,
707                }),
708                data: None,
709                max_access_count: None,
710                access_count: 0,
711                disabled: false,
712                hide_email: false,
713                revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
714                deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
715                expiration_date: None,
716                emails: Vec::new(),
717                auth_type: crate::AuthType::None,
718            }
719        }
720
721        /// Build the wire-format [`SendAccessResponse`] the server would return for a
722        /// text send encrypted by [`encrypt_send`].
723        fn text_send_response(send: &Send) -> SendAccessResponse {
724            SendAccessResponse {
725                id: Some("access-id".to_owned()),
726                type_: Some(SendType::Text),
727                name: Some(send.name.to_string()),
728                text: Some(SendAccessTextResponse {
729                    text: send
730                        .text
731                        .as_ref()
732                        .and_then(|t| t.text.as_ref())
733                        .map(|t| t.to_string()),
734                    hidden: false,
735                }),
736                file: None,
737                data: None,
738                expiration_date: None,
739                creator_identifier: None,
740            }
741        }
742
743        /// The load-bearing test for this whole flow: a send encrypted through the
744        /// authenticated key-store path must be decryptable by a key derived *only* from the
745        /// URL fragment. This pins [`SendAccessKey::from_url_b64`]'s derivation
746        /// (`derive_shareable_key(secret, "send", Some("send"))`) byte-for-byte against
747        /// [`Send::derive_shareable_key`]. If the two ever drift, every `bw receive`
748        /// silently fails to decrypt.
749        #[test]
750        fn decrypts_ciphertext_produced_by_the_authenticated_path() {
751            let send = encrypt_send(text_send_view("This is a test", "Test"));
752            let response = text_send_response(&send);
753
754            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
755            let view = access_key.decrypt_response(response).expect("decrypts");
756
757            assert_eq!(view.name.as_deref(), Some("Test"));
758            assert_eq!(
759                view.text.expect("text present").text.as_deref(),
760                Some("This is a test")
761            );
762        }
763
764        #[test]
765        fn decrypts_file_name_produced_by_the_authenticated_path() {
766            let mut view = text_send_view("unused", "File Send");
767            view.r#type = SendType::File;
768            view.text = None;
769            view.file = Some(SendFileView {
770                id: Some("file-id".to_owned()),
771                file_name: "secrets.txt".to_owned(),
772                size: Some("11".to_owned()),
773                size_name: Some("11 B".to_owned()),
774            });
775            let send = encrypt_send(view);
776            let file = send.file.expect("file present");
777
778            let response = SendAccessResponse {
779                id: Some("access-id".to_owned()),
780                type_: Some(SendType::File),
781                name: Some(send.name.to_string()),
782                text: None,
783                file: Some(SendAccessFileResponse {
784                    id: file.id.clone(),
785                    file_name: Some(file.file_name.to_string()),
786                    size: file.size.clone(),
787                    size_name: file.size_name.clone(),
788                }),
789                data: None,
790                expiration_date: None,
791                creator_identifier: None,
792            };
793
794            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
795            let view = access_key.decrypt_response(response).expect("decrypts");
796
797            let decrypted_file = view.file.expect("file present");
798            assert_eq!(decrypted_file.file_name.as_deref(), Some("secrets.txt"));
799            assert_eq!(decrypted_file.size.as_deref(), Some("11"));
800            assert_eq!(decrypted_file.id.as_deref(), Some("file-id"));
801            assert_eq!(view.name.as_deref(), Some("File Send"));
802        }
803
804        /// A file-send blob is a whole-buffer `EncString`, encrypted under the same stretched
805        /// send key. Round-trip it through the authenticated encrypt path (what
806        /// `create_file_send` uploads) and the anonymous decrypt path (what `bw receive`
807        /// downloads).
808        #[test]
809        fn decrypt_file_buffer_round_trips_with_the_authenticated_path() {
810            let plaintext = b"file send contents".to_vec();
811
812            let crypto = create_test_crypto_with_user_key(user_key());
813            let mut ctx = crypto.context();
814            let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
815                .expect("url key decodes")
816                .into_bytes();
817            let send_key = Send::derive_shareable_key(&mut ctx, &raw_key).expect("key derives");
818            let encrypted = OctetStreamBytes::from(plaintext.clone())
819                .encrypt(&mut ctx, send_key)
820                .expect("buffer encrypts")
821                .to_buffer()
822                .expect("buffer serializes");
823
824            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
825            let decrypted = access_key
826                .decrypt_file_buffer(&encrypted)
827                .expect("buffer decrypts");
828
829            assert_eq!(decrypted, plaintext);
830        }
831
832        /// `hash_password_b64` and [`SendAuthType::auth_data`] must produce the same
833        /// `password_hash_b64`: `auth_data` is what `bw send create --password` stored on the
834        /// server, and `hash_password_b64` is what `bw receive` presents to the token grant.
835        /// Any divergence turns every password-protected receive into an opaque 400.
836        #[test]
837        fn hash_password_b64_matches_send_auth_type_auth_data() {
838            let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
839                .expect("url key decodes")
840                .into_bytes();
841
842            let (created_hash, emails) = SendAuthType::Password {
843                password: "hunter2".to_owned(),
844            }
845            .auth_data(&raw_key);
846            assert_eq!(emails, None);
847
848            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
849            let receive_hash = access_key.hash_password_b64("hunter2");
850
851            assert_eq!(
852                created_hash,
853                Some(receive_hash),
854                "receive's password hash must match the one `bw send create` stored"
855            );
856        }
857
858        #[test]
859        fn hash_password_b64_is_salted_with_the_send_key() {
860            // Different sends (different URL keys) must produce different hashes for the
861            // same password, otherwise a hash captured from one send would unlock another.
862            let a = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
863            let b = SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
864            assert_ne!(
865                a.hash_password_b64("hunter2"),
866                b.hash_password_b64("hunter2")
867            );
868        }
869
870        #[test]
871        fn from_url_b64_accepts_padded_and_unpadded() {
872            // The fragment form is unpadded, but a caller pasting a padded key (or a URL that
873            // has been round-tripped through a tool that re-adds padding) should still work.
874            let unpadded = SendAccessKey::from_url_b64(URL_KEY).expect("unpadded parses");
875            let padded =
876                SendAccessKey::from_url_b64(&format!("{URL_KEY}==")).expect("padded parses");
877            assert_eq!(
878                unpadded.hash_password_b64("p"),
879                padded.hash_password_b64("p"),
880                "padded and unpadded forms must derive the same key"
881            );
882        }
883
884        #[test]
885        fn from_url_b64_rejects_invalid_base64() {
886            assert!(matches!(
887                SendAccessKey::from_url_b64("not valid base64!"),
888                Err(SendAccessKeyError::InvalidEncoding)
889            ));
890        }
891
892        #[test]
893        fn from_url_b64_rejects_wrong_length() {
894            // Too short (8 bytes) and too long (32 bytes) must both be rejected rather than
895            // silently truncated or zero-padded into a different key.
896            for bad in ["AAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"] {
897                assert!(
898                    matches!(
899                        SendAccessKey::from_url_b64(bad),
900                        Err(SendAccessKeyError::InvalidLength)
901                    ),
902                    "expected InvalidLength for {bad:?}"
903                );
904            }
905            assert!(matches!(
906                SendAccessKey::from_url_b64(""),
907                Err(SendAccessKeyError::InvalidLength)
908            ));
909        }
910
911        /// A send with no name and no text body must decrypt to a view with `None` fields
912        /// rather than erroring — legacy prints whatever it got, and requiring a name would
913        /// make otherwise-valid sends unreadable.
914        #[test]
915        fn decrypt_response_tolerates_absent_fields() {
916            let response = SendAccessResponse {
917                id: None,
918                type_: None,
919                name: None,
920                text: Some(SendAccessTextResponse {
921                    text: None,
922                    hidden: true,
923                }),
924                file: None,
925                data: None,
926                expiration_date: None,
927                creator_identifier: None,
928            };
929
930            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
931            let view = access_key.decrypt_response(response).expect("decrypts");
932
933            assert_eq!(view.name, None);
934            assert_eq!(view.type_, None);
935            let text = view.text.expect("text block present");
936            assert_eq!(text.text, None);
937            assert!(text.hidden);
938        }
939
940        #[test]
941        fn decrypt_response_restores_item_id_from_metadata() {
942            let item_id: CipherId = TEST_ITEM_ID.parse().unwrap();
943            let response = SendAccessResponse {
944                id: None,
945                type_: Some(SendType::Item),
946                name: None,
947                text: None,
948                file: None,
949                data: Some(SendAccessItemResponse {
950                    encryption_version: None,
951                    data: Some(TEST_VECTOR_ITEM_SEND_DATA.to_owned()),
952                    metadata: SendItemMetadata { item_id },
953                }),
954                expiration_date: None,
955                creator_identifier: None,
956            };
957
958            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
959            let view = access_key.decrypt_response(response).expect("decrypts");
960
961            let cipher = view.data.and_then(|d| d.data).expect("item present");
962            assert_eq!(cipher.id, Some(item_id));
963        }
964
965        #[test]
966        fn decrypt_response_errors_on_a_key_that_does_not_match() {
967            let send = encrypt_send(text_send_view("This is a test", "Test"));
968            let response = SendAccessResponse {
969                id: None,
970                type_: Some(SendType::Text),
971                name: Some(send.name.to_string()),
972                text: None,
973                file: None,
974                data: None,
975                expiration_date: None,
976                creator_identifier: None,
977            };
978
979            // A syntactically valid but wrong URL key must fail loudly, not return garbage.
980            let wrong_key =
981                SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
982            assert!(wrong_key.decrypt_response(response).is_err());
983        }
984
985        #[test]
986        fn decrypt_response_errors_on_a_malformed_enc_string() {
987            let response = SendAccessResponse {
988                id: None,
989                type_: Some(SendType::Text),
990                name: Some("this is not an EncString".to_owned()),
991                text: None,
992                file: None,
993                data: None,
994                expiration_date: None,
995                creator_identifier: None,
996            };
997
998            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
999            assert!(access_key.decrypt_response(response).is_err());
1000        }
1001
1002        /// The `--fullObject` JSON dump is a user-facing contract; pin its camelCase wire
1003        /// shape (including `type` rather than `type_`) so a field rename can't silently
1004        /// break scripts parsing `bw receive --fullObject`.
1005        #[test]
1006        fn send_access_view_serializes_in_camel_case() {
1007            let view = crate::SendAccessView {
1008                id: Some("access-id".to_owned()),
1009                type_: Some(SendType::File),
1010                name: Some("name".to_owned()),
1011                text: None,
1012                file: Some(crate::SendAccessFileView {
1013                    id: Some("file-id".to_owned()),
1014                    file_name: Some("secrets.txt".to_owned()),
1015                    size: Some("11".to_owned()),
1016                    size_name: Some("11 B".to_owned()),
1017                }),
1018                data: None,
1019                expiration_date: None,
1020                creator_identifier: None,
1021            };
1022
1023            let json = serde_json::to_value(&view).expect("serializes");
1024            assert_eq!(json["type"], serde_json::json!(1));
1025            assert_eq!(json["file"]["fileName"], serde_json::json!("secrets.txt"));
1026            assert_eq!(json["file"]["sizeName"], serde_json::json!("11 B"));
1027            assert_eq!(json["creatorIdentifier"], serde_json::Value::Null);
1028        }
1029
1030        #[test]
1031        fn decrypt_send_access_success() {
1032            let send = encrypt_send(text_send_view("This is a test", "Test"));
1033            let view =
1034                SendClient::decrypt_send_access(URL_KEY.to_owned(), text_send_response(&send))
1035                    .expect("decrypts");
1036
1037            assert_eq!(view.name.as_deref(), Some("Test"));
1038            assert_eq!(
1039                view.text.expect("text present").text.as_deref(),
1040                Some("This is a test")
1041            );
1042        }
1043
1044        #[test]
1045        fn decrypt_send_access_malformed_b64() {
1046            let response = SendAccessResponse {
1047                id: Some("access-id".to_owned()),
1048                type_: Some(SendType::Text),
1049                name: Some("Test".to_owned()),
1050                text: None,
1051                file: None,
1052                data: None,
1053                expiration_date: None,
1054                creator_identifier: None,
1055            };
1056
1057            let result = SendClient::decrypt_send_access("not valid base64!".to_owned(), response);
1058
1059            assert!(matches!(
1060                result.unwrap_err(),
1061                SendAccessDecryptError::Key(SendAccessKeyError::InvalidEncoding)
1062            ));
1063        }
1064    }
1065}