Skip to main content

bitwarden_send/
access.rs

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