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