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))]
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}
203
204// ===== Anonymous access key =====
205
206/// Symmetric key material for an anonymous Send access, derived entirely from the URL
207/// fragment — no account key store or logged-in user involved. This is the only Send flow
208/// whose key doesn't come from the user's key store, hence a standalone type rather than a
209/// [`bitwarden_crypto::KeyStoreContext`] slot.
210///
211/// The key is opaque by design: callers need to *use* it three ways (hash a password,
212/// decrypt a response, decrypt a downloaded blob) but never need to *see* it.
213pub struct SendAccessKey {
214    /// The raw fragment key, exactly as decoded from the URL — not run through the KDF that
215    /// derives [`Self::key`] below. Retained because the send password hash is salted with
216    /// these raw bytes, not with the derived key.
217    secret: Zeroizing<[u8; SEND_KEY_LEN]>,
218    /// The send's actual symmetric key, derived from `secret` via `derive_shareable_key`.
219    /// This is what encrypts the send's fields and file blob.
220    key: SymmetricCryptoKey,
221}
222
223impl SendAccessKey {
224    /// Parse the URL-safe-base64 key from a Send URL fragment and stretch it into the
225    /// send's symmetric key. Equivalent to the legacy clients' `Utils.fromUrlB64ToArray`
226    /// followed by `KeyService.makeSendKey`.
227    ///
228    /// The `"send"`/`Some("send")` name/info pair must stay in lockstep with
229    /// `Send::derive_shareable_key` — that is what `bw send create` used to encrypt the send,
230    /// so any divergence makes every send undecryptable through this path. The round-trip
231    /// test below pins the two together.
232    pub fn from_url_b64(key_b64: &str) -> Result<Self, SendAccessKeyError> {
233        // Wrap the decoded bytes in `Zeroizing` before any length check so a wrong-length
234        // key is still scrubbed rather than left in freed memory.
235        let decoded = Zeroizing::new(
236            B64Url::try_from(key_b64)
237                .map_err(|_| SendAccessKeyError::InvalidEncoding)?
238                .into_bytes(),
239        );
240        if decoded.len() != SEND_KEY_LEN {
241            return Err(SendAccessKeyError::InvalidLength);
242        }
243        let mut secret = Zeroizing::new([0u8; SEND_KEY_LEN]);
244        secret.copy_from_slice(&decoded);
245
246        let key = SymmetricCryptoKey::Aes256CbcHmacKey(derive_shareable_key(
247            secret.clone(),
248            "send",
249            Some("send"),
250        ));
251
252        Ok(Self { secret, key })
253    }
254
255    /// PBKDF2-HMAC-SHA256 over `password`, `SEND_ITERATIONS` (100,000) rounds, salted with the
256    /// raw URL key — the `password_hash_b64` credential the send-access token grant expects.
257    ///
258    /// Identical recipe to `SendAuthType::auth_data`, which is what `bw send create --password`
259    /// stored on the server. A pinning test asserts the two agree; if they ever diverge, every
260    /// password-protected receive fails with an opaque server-side rejection.
261    pub fn hash_password_b64(&self, password: &str) -> String {
262        let hashed =
263            bitwarden_crypto::pbkdf2(password.as_bytes(), self.secret.as_slice(), SEND_ITERATIONS);
264        B64::from(hashed.as_slice()).to_string()
265    }
266
267    /// Decrypt a [`SendAccessResponse`]'s encrypted fields into a plaintext
268    /// [`SendAccessView`].
269    pub fn decrypt_response(
270        &self,
271        response: SendAccessResponse,
272    ) -> Result<SendAccessView, SendAccessDecryptError> {
273        let text = match response.text {
274            Some(t) => Some(SendAccessTextView {
275                text: self.decrypt_optional(t.text)?,
276                hidden: t.hidden,
277            }),
278            None => None,
279        };
280        let file = match response.file {
281            Some(f) => Some(SendAccessFileView {
282                id: f.id,
283                file_name: self.decrypt_optional(f.file_name)?,
284                size: f.size,
285                size_name: f.size_name,
286            }),
287            None => None,
288        };
289        let data = match response.data {
290            Some(d) => {
291                let key_store: KeyStore<KeySlotIds> = KeyStore::default();
292                let mut ctx = key_store.context_mut();
293                let key = ctx.add_local_symmetric_key(self.key.clone());
294                let cipher = serde_json::from_str::<Cipher>(
295                    d.data.expect("Item type Send requires data field").as_str(),
296                );
297                match cipher {
298                    Ok(c) => {
299                        let cipher_view: CipherView = c.decrypt(&mut ctx, key)?;
300                        Some(SendAccessItemView {
301                            data: Some(cipher_view),
302                        })
303                    }
304                    Err(_) => None,
305                }
306            }
307            None => None,
308        };
309
310        Ok(SendAccessView {
311            id: response.id,
312            type_: response.type_,
313            name: self.decrypt_optional(response.name)?,
314            text,
315            file,
316            data,
317            expiration_date: response.expiration_date,
318            creator_identifier: response.creator_identifier,
319        })
320    }
321
322    /// Decrypt a downloaded file-send blob. The blob is a single whole-buffer [`EncString`]
323    /// (not the chunked attachment format), matching the legacy clients'
324    /// `EncArrayBuffer.fromResponse` + `EncryptService.decryptFileData`.
325    pub fn decrypt_file_buffer(&self, buffer: &[u8]) -> Result<Vec<u8>, SendAccessDecryptError> {
326        Ok(EncString::from_buffer(buffer)?.decrypt_with_key(&self.key)?)
327    }
328
329    /// Parse and decrypt an optional wire-format [`EncString`] field. Absent fields stay
330    /// absent — the caller decides whether a missing field is fatal.
331    fn decrypt_optional(
332        &self,
333        value: Option<String>,
334    ) -> Result<Option<String>, SendAccessDecryptError> {
335        match value {
336            Some(s) => Ok(Some(s.parse::<EncString>()?.decrypt_with_key(&self.key)?)),
337            None => Ok(None),
338        }
339    }
340}
341
342/// Error returned when accessing a send fails.
343#[bitwarden_error(flat)]
344#[derive(Debug, Error)]
345pub enum AccessSendError {
346    /// An API or network error occurred.
347    #[error(transparent)]
348    Api(#[from] ApiError),
349    /// The response body could not be parsed into a [`SendAccessResponse`] — either a
350    /// required field was missing, the send type was an unrecognized value, or a date
351    /// field was malformed.
352    #[error(transparent)]
353    Parse(#[from] SendParseError),
354}
355
356/// Error returned when getting send file download data fails.
357#[bitwarden_error(flat)]
358#[derive(Debug, Error)]
359pub enum GetFileDownloadDataError {
360    /// An API or network error occurred.
361    #[error(transparent)]
362    Api(#[from] ApiError),
363}
364
365// ===== HTTP request functions =====
366
367async fn access_send(
368    api_client: &ApiClient,
369    access_token: &str,
370) -> Result<SendAccessResponse, AccessSendError> {
371    let resp = api_client
372        .sends_api()
373        .access_using_auth(access_token)
374        .await?;
375    Ok(resp.try_into()?)
376}
377
378async fn get_file_download_data(
379    api_client: &ApiClient,
380    file_id: &str,
381    access_token: &str,
382) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
383    let resp = api_client
384        .sends_api()
385        .get_send_file_download_data_using_auth(file_id, access_token)
386        .await?;
387    Ok(resp.into())
388}
389
390// ===== Conversions from API response models =====
391
392impl TryFrom<models::SendAccessResponseModel> for SendAccessResponse {
393    type Error = SendParseError;
394
395    fn try_from(r: models::SendAccessResponseModel) -> Result<Self, Self::Error> {
396        Ok(SendAccessResponse {
397            id: r.id,
398            type_: r.r#type.map(SendType::try_from).transpose()?,
399            name: r.name,
400            text: r.text.map(|t| SendAccessTextResponse {
401                text: t.text,
402                hidden: t.hidden.unwrap_or(false),
403            }),
404            file: r.file.map(|f| SendAccessFileResponse {
405                id: f.id,
406                file_name: f.file_name,
407                size: f.size,
408                size_name: f.size_name,
409            }),
410            data: r.data.map(|dat| SendAccessItemResponse {
411                encryption_version: dat.encryption_version,
412                data: dat.data,
413            }),
414            expiration_date: r.expiration_date.map(|s| s.parse()).transpose()?,
415            creator_identifier: r.creator_identifier,
416        })
417    }
418}
419
420impl From<models::SendFileDownloadDataResponseModel> for SendFileDownloadData {
421    fn from(r: models::SendFileDownloadDataResponseModel) -> Self {
422        SendFileDownloadData {
423            id: r.id,
424            url: r.url,
425        }
426    }
427}
428
429// ===== SendClient methods =====
430
431#[cfg_attr(feature = "wasm", wasm_bindgen)]
432impl SendClient {
433    /// Accesses a send, authenticated with a send access token.
434    /// The returned [SendAccessResponse] contains encrypted fields that must be decrypted
435    /// client-side using the key derived from the URL fragment.
436    pub async fn access_send(
437        &self,
438        access_token: String,
439    ) -> Result<SendAccessResponse, AccessSendError> {
440        let config = self.client.internal.get_api_configurations();
441        access_send(&config.api_client, &access_token).await
442    }
443
444    /// Gets file download data for a file send, authenticated with a send access token.
445    pub async fn get_file_download_data(
446        &self,
447        access_token: String,
448        file_id: String,
449    ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
450        let config = self.client.internal.get_api_configurations();
451        get_file_download_data(&config.api_client, &file_id, &access_token).await
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use bitwarden_api_api::{
458        apis::ApiClient,
459        models::{
460            SendAccessResponseModel, SendFileDownloadDataResponseModel, SendFileModel,
461            SendTextModel, SendType,
462        },
463    };
464
465    use super::*;
466
467    const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
468    const FILE_ID: &str = "file-id-abc";
469    const ACCESS_TOKEN: &str = "send-access-token";
470
471    // ===== access_send =====
472
473    #[tokio::test]
474    async fn test_access_send_text() {
475        let api_client = ApiClient::new_mocked(|mock| {
476            mock.sends_api
477                .expect_access_using_auth()
478                .returning(|token| {
479                    assert_eq!(token, ACCESS_TOKEN);
480                    Ok(SendAccessResponseModel {
481                        object: Some("send-access".to_string()),
482                        id: Some(SEND_ID.to_string()),
483                        r#type: Some(SendType::Text),
484                        auth_type: None,
485                        name: Some("encrypted-name".to_string()),
486                        file: None,
487                        text: Some(Box::new(SendTextModel {
488                            text: Some("encrypted_send_text".to_string()),
489                            hidden: Some(true),
490                        })),
491                        data: None,
492                        expiration_date: Some("2025-01-10T00:00:00Z".to_string()),
493                        creator_identifier: Some("[email protected]".to_string()),
494                    })
495                })
496                .once();
497        });
498
499        let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
500
501        assert_eq!(result.id, Some(SEND_ID.to_string()));
502        assert_eq!(result.type_, Some(crate::SendType::Text));
503        assert_eq!(result.name, Some("encrypted-name".to_string()));
504        assert!(result.file.is_none());
505        let text = result.text.expect("text variant should be populated");
506        assert_eq!(text.text, Some("encrypted_send_text".to_string()));
507        assert!(text.hidden);
508        assert_eq!(
509            result.expiration_date,
510            Some("2025-01-10T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
511        );
512        assert_eq!(
513            result.creator_identifier,
514            Some("[email protected]".to_string())
515        );
516    }
517
518    #[tokio::test]
519    async fn test_access_send_file() {
520        let api_client = ApiClient::new_mocked(|mock| {
521            mock.sends_api
522                .expect_access_using_auth()
523                .returning(|token| {
524                    assert_eq!(token, ACCESS_TOKEN);
525                    Ok(SendAccessResponseModel {
526                        object: Some("send-access".to_string()),
527                        id: Some(SEND_ID.to_string()),
528                        r#type: Some(SendType::File),
529                        auth_type: None,
530                        name: Some("encrypted-name".to_string()),
531                        file: Some(Box::new(SendFileModel {
532                            id: Some(FILE_ID.to_string()),
533                            file_name: Some("encrypted-file-name".to_string()),
534                            size: Some("4200".to_string()),
535                            size_name: Some("4.2 KB".to_string()),
536                        })),
537                        text: None,
538                        data: None,
539                        expiration_date: None,
540                        creator_identifier: None,
541                    })
542                })
543                .once();
544        });
545
546        let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
547
548        assert_eq!(result.id, Some(SEND_ID.to_string()));
549        assert_eq!(result.type_, Some(crate::SendType::File));
550        assert_eq!(result.name, Some("encrypted-name".to_string()));
551        assert!(result.text.is_none());
552        let file = result.file.expect("file variant should be populated");
553        assert_eq!(file.id, Some(FILE_ID.to_string()));
554        assert_eq!(file.file_name, Some("encrypted-file-name".to_string()));
555        assert_eq!(file.size, Some("4200".to_string()));
556        assert_eq!(file.size_name, Some("4.2 KB".to_string()));
557        assert_eq!(result.expiration_date, None);
558        assert_eq!(result.creator_identifier, None);
559    }
560
561    #[tokio::test]
562    async fn test_access_send_http_error() {
563        let api_client = ApiClient::new_mocked(|mock| {
564            mock.sends_api
565                .expect_access_using_auth()
566                .returning(|_token| {
567                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
568                        "Simulated error",
569                    )))
570                })
571                .once();
572        });
573
574        let result = access_send(&api_client, ACCESS_TOKEN).await;
575
576        assert!(matches!(result.unwrap_err(), AccessSendError::Api(_)));
577    }
578
579    // ===== get_file_download_data =====
580
581    #[tokio::test]
582    async fn test_get_file_download_data() {
583        let api_client = ApiClient::new_mocked(|mock| {
584            mock.sends_api
585                .expect_get_send_file_download_data_using_auth()
586                .returning(|file_id, token| {
587                    assert_eq!(file_id, FILE_ID);
588                    assert_eq!(token, ACCESS_TOKEN);
589                    Ok(SendFileDownloadDataResponseModel {
590                        object: Some("send-fileDownload".to_string()),
591                        id: Some(FILE_ID.to_string()),
592                        url: Some("https://example.com/download".to_string()),
593                    })
594                })
595                .once();
596        });
597
598        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN)
599            .await
600            .unwrap();
601
602        assert_eq!(result.id, Some(FILE_ID.to_string()));
603        assert_eq!(result.url, Some("https://example.com/download".to_string()));
604    }
605
606    #[tokio::test]
607    async fn test_get_file_download_data_http_error() {
608        let api_client = ApiClient::new_mocked(|mock| {
609            mock.sends_api
610                .expect_get_send_file_download_data_using_auth()
611                .returning(|_file_id, _token| {
612                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
613                        "Simulated error",
614                    )))
615                })
616                .once();
617        });
618
619        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN).await;
620
621        assert!(matches!(
622            result.unwrap_err(),
623            GetFileDownloadDataError::Api(_)
624        ));
625    }
626
627    // ===== SendAccessKey =====
628
629    mod send_access_key {
630        //! Tests for [`SendAccessKey`]: URL-fragment key parsing/derivation, password hashing,
631        //! and decrypting a [`SendAccessResponse`] into a [`SendAccessView`].
632
633        use bitwarden_core::key_management::create_test_crypto_with_user_key;
634        use bitwarden_crypto::{OctetStreamBytes, PrimitiveEncryptable as _, SymmetricCryptoKey};
635
636        use crate::{
637            Send, SendAccessFileResponse, SendAccessKey, SendAccessKeyError, SendAccessResponse,
638            SendAccessTextResponse, SendAuthType, SendFileView, SendTextView, SendType, SendView,
639        };
640
641        /// The url-safe-base64 form of a 16-byte send key, as it appears in the trailing
642        /// segment of a send URL fragment. Shared with the tests in `send.rs`.
643        const URL_KEY: &str = "Pgui0FK85cNhBGWHAlBHBw";
644        const USER_KEY: &str = "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==";
645
646        fn user_key() -> SymmetricCryptoKey {
647            USER_KEY
648                .to_string()
649                .try_into()
650                .expect("valid test user key")
651        }
652
653        /// Encrypt a [`SendView`] through the authenticated (key-store) path — the exact
654        /// path `bw send create` takes — so the receive path can be checked against real
655        /// ciphertext rather than a fixture that could drift.
656        fn encrypt_send(view: SendView) -> Send {
657            create_test_crypto_with_user_key(user_key())
658                .encrypt(view)
659                .expect("send encrypts")
660        }
661
662        fn text_send_view(text: &str, name: &str) -> SendView {
663            SendView {
664                id: "3d80dd72-2d14-4f26-812c-b0f0018aa144".parse().ok(),
665                access_id: Some("ct2APRQtJk-BLLDwAYqhRA".to_owned()),
666                name: name.to_owned(),
667                notes: None,
668                key: Some(URL_KEY.to_owned()),
669                new_password: None,
670                has_password: false,
671                r#type: SendType::Text,
672                file: None,
673                text: Some(SendTextView {
674                    text: Some(text.to_owned()),
675                    hidden: false,
676                }),
677                data: None,
678                max_access_count: None,
679                access_count: 0,
680                disabled: false,
681                hide_email: false,
682                revision_date: "2024-01-07T23:56:48.207363Z".parse().unwrap(),
683                deletion_date: "2024-01-14T23:56:48Z".parse().unwrap(),
684                expiration_date: None,
685                emails: Vec::new(),
686                auth_type: crate::AuthType::None,
687            }
688        }
689
690        /// The load-bearing test for this whole flow: a send encrypted through the
691        /// authenticated key-store path must be decryptable by a key derived *only* from the
692        /// URL fragment. This pins [`SendAccessKey::from_url_b64`]'s derivation
693        /// (`derive_shareable_key(secret, "send", Some("send"))`) byte-for-byte against
694        /// [`Send::derive_shareable_key`]. If the two ever drift, every `bw receive`
695        /// silently fails to decrypt.
696        #[test]
697        fn decrypts_ciphertext_produced_by_the_authenticated_path() {
698            let send = encrypt_send(text_send_view("This is a test", "Test"));
699
700            let response = SendAccessResponse {
701                id: Some("access-id".to_owned()),
702                type_: Some(SendType::Text),
703                name: Some(send.name.to_string()),
704                text: Some(SendAccessTextResponse {
705                    text: send
706                        .text
707                        .as_ref()
708                        .and_then(|t| t.text.as_ref())
709                        .map(|t| t.to_string()),
710                    hidden: false,
711                }),
712                file: None,
713                data: None,
714                expiration_date: None,
715                creator_identifier: None,
716            };
717
718            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
719            let view = access_key.decrypt_response(response).expect("decrypts");
720
721            assert_eq!(view.name.as_deref(), Some("Test"));
722            assert_eq!(
723                view.text.expect("text present").text.as_deref(),
724                Some("This is a test")
725            );
726        }
727
728        #[test]
729        fn decrypts_file_name_produced_by_the_authenticated_path() {
730            let mut view = text_send_view("unused", "File Send");
731            view.r#type = SendType::File;
732            view.text = None;
733            view.file = Some(SendFileView {
734                id: Some("file-id".to_owned()),
735                file_name: "secrets.txt".to_owned(),
736                size: Some("11".to_owned()),
737                size_name: Some("11 B".to_owned()),
738            });
739            let send = encrypt_send(view);
740            let file = send.file.expect("file present");
741
742            let response = SendAccessResponse {
743                id: Some("access-id".to_owned()),
744                type_: Some(SendType::File),
745                name: Some(send.name.to_string()),
746                text: None,
747                file: Some(SendAccessFileResponse {
748                    id: file.id.clone(),
749                    file_name: Some(file.file_name.to_string()),
750                    size: file.size.clone(),
751                    size_name: file.size_name.clone(),
752                }),
753                data: None,
754                expiration_date: None,
755                creator_identifier: None,
756            };
757
758            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
759            let view = access_key.decrypt_response(response).expect("decrypts");
760
761            let decrypted_file = view.file.expect("file present");
762            assert_eq!(decrypted_file.file_name.as_deref(), Some("secrets.txt"));
763            assert_eq!(decrypted_file.size.as_deref(), Some("11"));
764            assert_eq!(decrypted_file.id.as_deref(), Some("file-id"));
765            assert_eq!(view.name.as_deref(), Some("File Send"));
766        }
767
768        /// A file-send blob is a whole-buffer `EncString`, encrypted under the same stretched
769        /// send key. Round-trip it through the authenticated encrypt path (what
770        /// `create_file_send` uploads) and the anonymous decrypt path (what `bw receive`
771        /// downloads).
772        #[test]
773        fn decrypt_file_buffer_round_trips_with_the_authenticated_path() {
774            let plaintext = b"file send contents".to_vec();
775
776            let crypto = create_test_crypto_with_user_key(user_key());
777            let mut ctx = crypto.context();
778            let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
779                .expect("url key decodes")
780                .into_bytes();
781            let send_key = Send::derive_shareable_key(&mut ctx, &raw_key).expect("key derives");
782            let encrypted = OctetStreamBytes::from(plaintext.clone())
783                .encrypt(&mut ctx, send_key)
784                .expect("buffer encrypts")
785                .to_buffer()
786                .expect("buffer serializes");
787
788            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
789            let decrypted = access_key
790                .decrypt_file_buffer(&encrypted)
791                .expect("buffer decrypts");
792
793            assert_eq!(decrypted, plaintext);
794        }
795
796        /// `hash_password_b64` and [`SendAuthType::auth_data`] must produce the same
797        /// `password_hash_b64`: `auth_data` is what `bw send create --password` stored on the
798        /// server, and `hash_password_b64` is what `bw receive` presents to the token grant.
799        /// Any divergence turns every password-protected receive into an opaque 400.
800        #[test]
801        fn hash_password_b64_matches_send_auth_type_auth_data() {
802            let raw_key = bitwarden_encoding::B64Url::try_from(URL_KEY)
803                .expect("url key decodes")
804                .into_bytes();
805
806            let (created_hash, emails) = SendAuthType::Password {
807                password: "hunter2".to_owned(),
808            }
809            .auth_data(&raw_key);
810            assert_eq!(emails, None);
811
812            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
813            let receive_hash = access_key.hash_password_b64("hunter2");
814
815            assert_eq!(
816                created_hash,
817                Some(receive_hash),
818                "receive's password hash must match the one `bw send create` stored"
819            );
820        }
821
822        #[test]
823        fn hash_password_b64_is_salted_with_the_send_key() {
824            // Different sends (different URL keys) must produce different hashes for the
825            // same password, otherwise a hash captured from one send would unlock another.
826            let a = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
827            let b = SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
828            assert_ne!(
829                a.hash_password_b64("hunter2"),
830                b.hash_password_b64("hunter2")
831            );
832        }
833
834        #[test]
835        fn from_url_b64_accepts_padded_and_unpadded() {
836            // The fragment form is unpadded, but a caller pasting a padded key (or a URL that
837            // has been round-tripped through a tool that re-adds padding) should still work.
838            let unpadded = SendAccessKey::from_url_b64(URL_KEY).expect("unpadded parses");
839            let padded =
840                SendAccessKey::from_url_b64(&format!("{URL_KEY}==")).expect("padded parses");
841            assert_eq!(
842                unpadded.hash_password_b64("p"),
843                padded.hash_password_b64("p"),
844                "padded and unpadded forms must derive the same key"
845            );
846        }
847
848        #[test]
849        fn from_url_b64_rejects_invalid_base64() {
850            assert!(matches!(
851                SendAccessKey::from_url_b64("not valid base64!"),
852                Err(SendAccessKeyError::InvalidEncoding)
853            ));
854        }
855
856        #[test]
857        fn from_url_b64_rejects_wrong_length() {
858            // Too short (8 bytes) and too long (32 bytes) must both be rejected rather than
859            // silently truncated or zero-padded into a different key.
860            for bad in ["AAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"] {
861                assert!(
862                    matches!(
863                        SendAccessKey::from_url_b64(bad),
864                        Err(SendAccessKeyError::InvalidLength)
865                    ),
866                    "expected InvalidLength for {bad:?}"
867                );
868            }
869            assert!(matches!(
870                SendAccessKey::from_url_b64(""),
871                Err(SendAccessKeyError::InvalidLength)
872            ));
873        }
874
875        /// A send with no name and no text body must decrypt to a view with `None` fields
876        /// rather than erroring — legacy prints whatever it got, and requiring a name would
877        /// make otherwise-valid sends unreadable.
878        #[test]
879        fn decrypt_response_tolerates_absent_fields() {
880            let response = SendAccessResponse {
881                id: None,
882                type_: None,
883                name: None,
884                text: Some(SendAccessTextResponse {
885                    text: None,
886                    hidden: true,
887                }),
888                file: None,
889                data: None,
890                expiration_date: None,
891                creator_identifier: None,
892            };
893
894            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
895            let view = access_key.decrypt_response(response).expect("decrypts");
896
897            assert_eq!(view.name, None);
898            assert_eq!(view.type_, None);
899            let text = view.text.expect("text block present");
900            assert_eq!(text.text, None);
901            assert!(text.hidden);
902        }
903
904        #[test]
905        fn decrypt_response_errors_on_a_key_that_does_not_match() {
906            let send = encrypt_send(text_send_view("This is a test", "Test"));
907            let response = SendAccessResponse {
908                id: None,
909                type_: Some(SendType::Text),
910                name: Some(send.name.to_string()),
911                text: None,
912                file: None,
913                data: None,
914                expiration_date: None,
915                creator_identifier: None,
916            };
917
918            // A syntactically valid but wrong URL key must fail loudly, not return garbage.
919            let wrong_key =
920                SendAccessKey::from_url_b64("AAAAAAAAAAAAAAAAAAAAAA").expect("key parses");
921            assert!(wrong_key.decrypt_response(response).is_err());
922        }
923
924        #[test]
925        fn decrypt_response_errors_on_a_malformed_enc_string() {
926            let response = SendAccessResponse {
927                id: None,
928                type_: Some(SendType::Text),
929                name: Some("this is not an EncString".to_owned()),
930                text: None,
931                file: None,
932                data: None,
933                expiration_date: None,
934                creator_identifier: None,
935            };
936
937            let access_key = SendAccessKey::from_url_b64(URL_KEY).expect("key parses");
938            assert!(access_key.decrypt_response(response).is_err());
939        }
940
941        /// The `--fullObject` JSON dump is a user-facing contract; pin its camelCase wire
942        /// shape (including `type` rather than `type_`) so a field rename can't silently
943        /// break scripts parsing `bw receive --fullObject`.
944        #[test]
945        fn send_access_view_serializes_in_camel_case() {
946            let view = crate::SendAccessView {
947                id: Some("access-id".to_owned()),
948                type_: Some(SendType::File),
949                name: Some("name".to_owned()),
950                text: None,
951                file: Some(crate::SendAccessFileView {
952                    id: Some("file-id".to_owned()),
953                    file_name: Some("secrets.txt".to_owned()),
954                    size: Some("11".to_owned()),
955                    size_name: Some("11 B".to_owned()),
956                }),
957                data: None,
958                expiration_date: None,
959                creator_identifier: None,
960            };
961
962            let json = serde_json::to_value(&view).expect("serializes");
963            assert_eq!(json["type"], serde_json::json!(1));
964            assert_eq!(json["file"]["fileName"], serde_json::json!("secrets.txt"));
965            assert_eq!(json["file"]["sizeName"], serde_json::json!("11 B"));
966            assert_eq!(json["creatorIdentifier"], serde_json::Value::Null);
967        }
968    }
969}