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, 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
24/// Length in bytes of the raw Send key carried in a Send URL fragment. `pub(crate)` so
25/// `SendView::encrypt_composite` (`send.rs`) can generate keys of exactly this length,
26/// enforcing the relationship at compile time instead of relying on a test to catch drift.
27pub(crate) const SEND_KEY_LEN: usize = 16;
28
29// ===== Public output types (returned to callers) =====
30
31/// View of a send's accessible content, returned after a successful send access call.
32/// Name, text, file, and item fields are encrypted and must be decrypted client-side
33/// using the key derived from the URL fragment.
34#[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    /// The send access ID
39    pub id: Option<String>,
40    /// The send type.
41    #[serde(rename = "type")]
42    pub type_: Option<SendType>,
43    /// Encrypted send name
44    pub name: Option<String>,
45    /// Text content (if type is Text)
46    pub text: Option<SendAccessTextResponse>,
47    /// File metadata (if type is File)
48    pub file: Option<SendAccessFileResponse>,
49    /// Item metadata (if type is Item)
50    pub data: Option<SendAccessItemResponse>,
51    /// When the send expires.
52    pub expiration_date: Option<DateTime<Utc>>,
53    /// The creator's identifier (email), if not hidden
54    pub creator_identifier: Option<String>,
55}
56
57/// Encrypted text content of a text send.
58#[derive(Debug, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
61pub struct SendAccessTextResponse {
62    /// Encrypted text content
63    pub text: Option<String>,
64    /// Whether to hide the text by default
65    pub hidden: bool,
66}
67
68/// Encrypted file metadata of a file send.
69#[derive(Debug, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
72pub struct SendAccessFileResponse {
73    /// The file ID
74    pub id: Option<String>,
75    /// Encrypted file name
76    pub file_name: Option<String>,
77    /// File size in bytes as a string
78    pub size: Option<String>,
79    /// Human-readable size (e.g. "4.2 KB")
80    pub size_name: Option<String>,
81}
82
83/// Encrypted item metadata of an item send.
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
87pub struct SendAccessItemResponse {
88    /// The version of encryption used to encrypt the item data
89    pub encryption_version: Option<SendEncryptionType>,
90    /// The encrypted item data
91    pub data: Option<String>,
92}
93
94/// File download URL data returned from a send file access call.
95#[derive(Debug, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
98pub struct SendFileDownloadData {
99    /// The file ID
100    pub id: Option<String>,
101    /// The pre-signed download URL
102    pub url: Option<String>,
103}
104
105// ===== Decrypted views of an anonymous send access =====
106
107/// Plaintext view of a [`SendAccessResponse`], produced by
108/// [`SendAccessKey::decrypt_response`].
109///
110/// Mirrors the legacy CLI's `SendAccessResponse` output shape (`apps/cli`) so that a
111/// JSON dump of a received send stays recognizable to existing scripts, with two additions:
112/// `expirationDate` and `creatorIdentifier` are already present on the raw
113/// `SendAccessResponse` the server returns, but the legacy CLI's output shape drops them.
114///
115/// `text`/`file` are kept as independent `Option`s rather than collapsed into an enum with
116/// associated data: `type_` is `Option<SendType>` on the wire and an unrecognized or absent
117/// discriminant must still round-trip (both the legacy CLI and `bw receive` fall back to
118/// dumping whatever the server returned), which a total enum could not represent.
119#[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    /// The send access ID
124    pub id: Option<String>,
125    /// The send type.
126    #[serde(rename = "type")]
127    pub type_: Option<SendType>,
128    /// The decrypted send name. `None` when the send has no name, which is not an error —
129    /// a nameless text send still has printable content.
130    pub name: Option<String>,
131    /// Decrypted text content (if type is Text)
132    pub text: Option<SendAccessTextView>,
133    /// Decrypted file metadata (if type is File)
134    pub file: Option<SendAccessFileView>,
135    /// Decrypted item content (if type is Item)
136    pub data: Option<SendAccessItemView>,
137    /// When the send expires.
138    pub expiration_date: Option<DateTime<Utc>>,
139    /// The creator's identifier (email), if not hidden
140    pub creator_identifier: Option<String>,
141}
142
143/// Decrypted text content of a text send.
144#[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    /// The decrypted text content
149    pub text: Option<String>,
150    /// Whether to hide the text by default
151    pub hidden: bool,
152}
153
154/// Decrypted file metadata of a file send.
155#[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    /// The file ID
160    pub id: Option<String>,
161    /// The decrypted file name
162    pub file_name: Option<String>,
163    /// File size in bytes as a string
164    pub size: Option<String>,
165    /// Human-readable size (e.g. "4.2 KB")
166    pub size_name: Option<String>,
167}
168
169/// Decrypted item metadata of an item send.
170#[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    /// The decrypted Cipher data
175    pub data: Option<CipherView>,
176}
177
178// ===== Error types =====
179
180/// Error returned when the key from a send URL fragment cannot be turned into a
181/// [`SendAccessKey`]. Deliberately carries no key material.
182#[bitwarden_error(flat)]
183#[derive(Debug, Error)]
184pub enum SendAccessKeyError {
185    /// The fragment key was not valid URL-safe base64.
186    #[error("The send key is not valid url-safe base64")]
187    InvalidEncoding,
188    /// The decoded key was not `SEND_KEY_LEN` (16) bytes long.
189    #[error("The send key must be {SEND_KEY_LEN} bytes")]
190    InvalidLength,
191}
192
193/// Error returned when decrypting an anonymous send access response or file blob fails.
194/// Wraps [`CryptoError`], which never embeds plaintext or key material in its messages.
195#[bitwarden_error(flat)]
196#[derive(Debug, Error)]
197pub enum SendAccessDecryptError {
198    /// The ciphertext was malformed, or the key derived from the URL fragment does not
199    /// decrypt it (wrong key, or a tampered response).
200    #[error(transparent)]
201    Crypto(#[from] CryptoError),
202    /// The key could not be derived from the URL fragment
203    #[error(transparent)]
204    Key(#[from] SendAccessKeyError),
205}
206
207// ===== Anonymous access key =====
208
209/// Symmetric key material for an anonymous Send access, derived entirely from the URL
210/// fragment — no account key store or logged-in user involved. This is the only Send flow
211/// whose key doesn't come from the user's key store, hence a standalone type rather than a
212/// [`bitwarden_crypto::KeyStoreContext`] slot.
213///
214/// The key is opaque by design: callers need to *use* it three ways (hash a password,
215/// decrypt a response, decrypt a downloaded blob) but never need to *see* it.
216pub struct SendAccessKey {
217    /// The raw fragment key, exactly as decoded from the URL — not run through the KDF that
218    /// derives [`Self::key`] below. Retained because the send password hash is salted with
219    /// these raw bytes, not with the derived key.
220    secret: Zeroizing<[u8; SEND_KEY_LEN]>,
221    /// The send's actual symmetric key, derived from `secret` via `derive_shareable_key`.
222    /// This is what encrypts the send's fields and file blob.
223    key: SymmetricCryptoKey,
224}
225
226impl SendAccessKey {
227    /// Parse the URL-safe-base64 key from a Send URL fragment and stretch it into the
228    /// send's symmetric key. Equivalent to the legacy clients' `Utils.fromUrlB64ToArray`
229    /// followed by `KeyService.makeSendKey`.
230    ///
231    /// The `"send"`/`Some("send")` name/info pair must stay in lockstep with
232    /// `Send::derive_shareable_key` — that is what `bw send create` used to encrypt the send,
233    /// so any divergence makes every send undecryptable through this path. The round-trip
234    /// test below pins the two together.
235    pub fn from_url_b64(key_b64: &str) -> Result<Self, SendAccessKeyError> {
236        // Wrap the decoded bytes in `Zeroizing` before any length check so a wrong-length
237        // key is still scrubbed rather than left in freed memory.
238        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    /// PBKDF2-HMAC-SHA256 over `password`, `SEND_ITERATIONS` (100,000) rounds, salted with the
259    /// raw URL key — the `password_hash_b64` credential the send-access token grant expects.
260    ///
261    /// Identical recipe to `SendAuthType::auth_data`, which is what `bw send create --password`
262    /// stored on the server. A pinning test asserts the two agree; if they ever diverge, every
263    /// password-protected receive fails with an opaque server-side rejection.
264    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    /// Decrypt a [`SendAccessResponse`]'s encrypted fields into a plaintext
271    /// [`SendAccessView`].
272    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    /// Decrypt a downloaded file-send blob. The blob is a single whole-buffer [`EncString`]
329    /// (not the chunked attachment format), matching the legacy clients'
330    /// `EncArrayBuffer.fromResponse` + `EncryptService.decryptFileData`.
331    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    /// Parse and decrypt an optional wire-format [`EncString`] field. Absent fields stay
336    /// absent — the caller decides whether a missing field is fatal.
337    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/// Error returned when accessing a send fails.
349#[bitwarden_error(flat)]
350#[derive(Debug, Error)]
351pub enum AccessSendError {
352    /// An API or network error occurred.
353    #[error(transparent)]
354    Api(#[from] ApiError),
355    /// The response body could not be parsed into a [`SendAccessResponse`] — either a
356    /// required field was missing, the send type was an unrecognized value, or a date
357    /// field was malformed.
358    #[error(transparent)]
359    Parse(#[from] SendParseError),
360}
361
362/// Error returned when getting send file download data fails.
363#[bitwarden_error(flat)]
364#[derive(Debug, Error)]
365pub enum GetFileDownloadDataError {
366    /// An API or network error occurred.
367    #[error(transparent)]
368    Api(#[from] ApiError),
369}
370
371// ===== HTTP request functions =====
372
373async 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
396// ===== Conversions from API response models =====
397
398impl 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// ===== SendClient methods =====
436
437#[cfg_attr(feature = "wasm", wasm_bindgen)]
438impl SendClient {
439    /// Accesses a send, authenticated with a send access token.
440    /// The returned [SendAccessResponse] contains encrypted fields that must be decrypted
441    /// client-side using the key derived from the URL fragment.
442    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    /// Gets file download data for a file send, authenticated with a send access token.
451    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    /// Decrypt a [`SendAccessResponse`] into a [`SendAccessView`].
461    ///
462    /// `key_b64` is the URL-safe-base64 send key from the trailing segment of the send URL
463    /// fragment (16 bytes when decoded) — the same form [`SendAccessKey::from_url_b64`] accepts
464    ///
465    /// This is a temporary function to support the transition to fully using the SDK for Send logic
466    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    // ===== access_send =====
492
493    #[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    // ===== get_file_download_data =====
600
601    #[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    // ===== SendAccessKey =====
648
649    mod send_access_key {
650        //! Tests for [`SendAccessKey`]: URL-fragment key parsing/derivation, password hashing,
651        //! and decrypting a [`SendAccessResponse`] into a [`SendAccessView`].
652
653        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        /// The url-safe-base64 form of a 16-byte send key, as it appears in the trailing
663        /// segment of a send URL fragment. Shared with the tests in `send.rs`.
664        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        /// Encrypt a [`SendView`] through the authenticated (key-store) path — the exact
675        /// path `bw send create` takes — so the receive path can be checked against real
676        /// ciphertext rather than a fixture that could drift.
677        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        /// Build the wire-format [`SendAccessResponse`] the server would return for a
712        /// text send encrypted by [`encrypt_send`].
713        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        /// The load-bearing test for this whole flow: a send encrypted through the
734        /// authenticated key-store path must be decryptable by a key derived *only* from the
735        /// URL fragment. This pins [`SendAccessKey::from_url_b64`]'s derivation
736        /// (`derive_shareable_key(secret, "send", Some("send"))`) byte-for-byte against
737        /// [`Send::derive_shareable_key`]. If the two ever drift, every `bw receive`
738        /// silently fails to decrypt.
739        #[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        /// A file-send blob is a whole-buffer `EncString`, encrypted under the same stretched
795        /// send key. Round-trip it through the authenticated encrypt path (what
796        /// `create_file_send` uploads) and the anonymous decrypt path (what `bw receive`
797        /// downloads).
798        #[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        /// `hash_password_b64` and [`SendAuthType::auth_data`] must produce the same
823        /// `password_hash_b64`: `auth_data` is what `bw send create --password` stored on the
824        /// server, and `hash_password_b64` is what `bw receive` presents to the token grant.
825        /// Any divergence turns every password-protected receive into an opaque 400.
826        #[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            // Different sends (different URL keys) must produce different hashes for the
851            // same password, otherwise a hash captured from one send would unlock another.
852            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            // The fragment form is unpadded, but a caller pasting a padded key (or a URL that
863            // has been round-tripped through a tool that re-adds padding) should still work.
864            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            // Too short (8 bytes) and too long (32 bytes) must both be rejected rather than
885            // silently truncated or zero-padded into a different key.
886            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        /// A send with no name and no text body must decrypt to a view with `None` fields
902        /// rather than erroring — legacy prints whatever it got, and requiring a name would
903        /// make otherwise-valid sends unreadable.
904        #[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            // A syntactically valid but wrong URL key must fail loudly, not return garbage.
945            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        /// The `--fullObject` JSON dump is a user-facing contract; pin its camelCase wire
968        /// shape (including `type` rather than `type_`) so a field rename can't silently
969        /// break scripts parsing `bw receive --fullObject`.
970        #[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}