Skip to main content

bitwarden_send/
receive_client.rs

1use bitwarden_auth::{
2    AuthClientExt as _,
3    send_access::{SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse},
4};
5use bitwarden_core::{Client, ClientSettings};
6
7use crate::{
8    AccessSendError, GetFileDownloadDataError, SendAccessDecryptError, SendAccessKey,
9    SendAccessKeyError, SendAccessResponse, SendAccessView, SendFileDownloadData,
10    access::{access_send, get_file_download_data},
11};
12
13/// Client dedicated to receiving (anonymously accessing) a Send.
14///
15/// Receiving is the only Send operation that may target a different Bitwarden instance than the
16/// one the user is signed into: the target server is dictated by the Send link itself (e.g. a
17/// self-hosted or other-region server). This client is therefore built from its own
18/// [`ClientSettings`] and carries no user identity, tokens, or key store. Creating and editing
19/// Sends never needs this, and should go through the regular
20/// [`SendClient`](crate::SendClient).
21#[bitwarden_ffi::wasm_object]
22pub struct SendReceiveClient {
23    client: Client,
24}
25
26#[bitwarden_ffi::wasm_export]
27impl SendReceiveClient {
28    /// Create a receive client targeting the instance described by `settings`.
29    #[cfg_attr(feature = "wasm", wasm_bindgen(constructor))]
30    pub fn new(settings: Option<ClientSettings>) -> Self {
31        Self {
32            client: Client::new(settings),
33        }
34    }
35
36    /// Requests a send access token from the identity server of the instance this client targets.
37    ///
38    /// For a password-protected Send, the request's credentials carry the hash returned by
39    /// [`Self::hash_send_password`]. Minting the token here, rather than through a signed-in
40    /// client, guarantees it (and any password hash) is only ever sent to the server that hosts
41    /// the Send.
42    pub async fn request_send_access_token(
43        &self,
44        request: SendAccessTokenRequest,
45    ) -> Result<SendAccessTokenResponse, SendAccessTokenError> {
46        self.client
47            .auth_new()
48            .send_access()
49            .request_send_access_token(request)
50            .await
51    }
52
53    /// Hash `password` with the URL-safe-base64 send key into the `password_hash_b64` credential
54    /// expected by [`Self::request_send_access_token`].
55    pub fn hash_send_password(
56        &self,
57        key_b64: String,
58        password: String,
59    ) -> Result<String, SendAccessKeyError> {
60        Ok(SendAccessKey::from_url_b64(&key_b64)?.hash_password_b64(&password))
61    }
62
63    /// Accesses a send, authenticated with a send access token.
64    /// The returned [SendAccessResponse] contains encrypted fields that must be decrypted
65    /// with [`Self::decrypt_send_access`] using the key from the URL fragment.
66    pub async fn access_send(
67        &self,
68        access_token: String,
69    ) -> Result<SendAccessResponse, AccessSendError> {
70        let config = self.client.internal.get_api_configurations();
71        access_send(&config.api_client, &access_token).await
72    }
73
74    /// Gets file download data for a file send, authenticated with a send access token.
75    pub async fn get_file_download_data(
76        &self,
77        access_token: String,
78        file_id: String,
79    ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
80        let config = self.client.internal.get_api_configurations();
81        get_file_download_data(&config.api_client, &file_id, &access_token).await
82    }
83
84    /// Decrypt a [`SendAccessResponse`] into a [`SendAccessView`] using the URL-safe-base64 send
85    /// key from the trailing segment of the send URL fragment.
86    pub fn decrypt_send_access(
87        &self,
88        key_b64: String,
89        response: SendAccessResponse,
90    ) -> Result<SendAccessView, SendAccessDecryptError> {
91        SendAccessKey::from_url_b64(&key_b64)?.decrypt_response(response)
92    }
93
94    /// Decrypt a downloaded file-send blob using the URL-safe-base64 send key.
95    pub fn decrypt_send_access_file(
96        &self,
97        key_b64: String,
98        buffer: &[u8],
99    ) -> Result<Vec<u8>, SendAccessDecryptError> {
100        SendAccessKey::from_url_b64(&key_b64)?.decrypt_file_buffer(buffer)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use bitwarden_core::{ClientSettings, key_management::create_test_crypto_with_user_key};
107    use bitwarden_crypto::{OctetStreamBytes, PrimitiveEncryptable as _, SymmetricCryptoKey};
108    use bitwarden_encoding::B64Url;
109
110    use super::*;
111    use crate::{Send, SendAccessKeyError, SendAccessTextResponse, SendType};
112
113    /// The url-safe-base64 form of a 16-byte send key, as it appears in a send URL fragment.
114    const URL_KEY: &str = "Pgui0FK85cNhBGWHAlBHBw";
115    const USER_KEY: &str =
116        "bYCsk857hl8QJJtxyRK65tjUrbxKC4aDifJpsml+NIv4W9cVgFvi3qVD+yJTUU2T4UwNKWYtt9pqWf7Q+2WCCg==";
117
118    fn client() -> SendReceiveClient {
119        SendReceiveClient::new(Some(ClientSettings {
120            api_url: "https://api.example.invalid".to_owned(),
121            identity_url: "https://identity.example.invalid".to_owned(),
122            ..Default::default()
123        }))
124    }
125
126    /// Encrypt `plaintext` with the key an anonymous receiver derives from [`URL_KEY`],
127    /// using the same derivation the authenticated send-creation path uses.
128    fn encrypt_with_url_key(plaintext: &str) -> String {
129        let user_key: SymmetricCryptoKey = USER_KEY.to_string().try_into().unwrap();
130        let store = create_test_crypto_with_user_key(user_key);
131        let mut ctx = store.context();
132        let raw = B64Url::try_from(URL_KEY).unwrap().into_bytes();
133        let key = Send::derive_shareable_key(&mut ctx, &raw).unwrap();
134        plaintext.encrypt(&mut ctx, key).unwrap().to_string()
135    }
136
137    fn response(name: &str, text: &str) -> SendAccessResponse {
138        SendAccessResponse {
139            id: Some("access-id".to_owned()),
140            type_: Some(SendType::Text),
141            name: Some(encrypt_with_url_key(name)),
142            text: Some(SendAccessTextResponse {
143                text: Some(encrypt_with_url_key(text)),
144                hidden: false,
145            }),
146            file: None,
147            data: None,
148            expiration_date: None,
149            creator_identifier: None,
150        }
151    }
152
153    #[test]
154    fn new_accepts_default_and_custom_settings() {
155        let _ = SendReceiveClient::new(None);
156        let _ = client();
157    }
158
159    #[test]
160    fn new_targets_the_configured_instance() {
161        let client = client();
162        let config = client.client.internal.get_api_configurations();
163        assert_eq!(config.api_config.base_path, "https://api.example.invalid");
164    }
165
166    #[test]
167    fn decrypt_send_access_round_trips() {
168        let view = client()
169            .decrypt_send_access(URL_KEY.to_owned(), response("Test", "This is a test"))
170            .expect("decrypts");
171
172        assert_eq!(view.name.as_deref(), Some("Test"));
173        assert_eq!(
174            view.text.expect("text present").text.as_deref(),
175            Some("This is a test")
176        );
177    }
178
179    #[test]
180    fn decrypt_send_access_rejects_malformed_key() {
181        let err = client()
182            .decrypt_send_access("not valid base64!".to_owned(), response("Test", "x"))
183            .unwrap_err();
184
185        assert!(matches!(
186            err,
187            SendAccessDecryptError::Key(SendAccessKeyError::InvalidEncoding)
188        ));
189    }
190
191    #[test]
192    fn decrypt_send_access_rejects_wrong_length_key() {
193        let err = client()
194            .decrypt_send_access("AAAA".to_owned(), response("Test", "x"))
195            .unwrap_err();
196
197        assert!(matches!(
198            err,
199            SendAccessDecryptError::Key(SendAccessKeyError::InvalidLength)
200        ));
201    }
202
203    #[test]
204    fn decrypt_send_access_errors_on_wrong_key() {
205        // A valid 16-byte key that differs from the one the response was encrypted with.
206        let other_key = "AAAAAAAAAAAAAAAAAAAAAA";
207        let err = client()
208            .decrypt_send_access(other_key.to_owned(), response("Test", "x"))
209            .unwrap_err();
210
211        assert!(matches!(err, SendAccessDecryptError::Crypto(_)));
212    }
213
214    #[test]
215    fn decrypt_send_access_file_round_trips() {
216        let plaintext = b"file send contents".to_vec();
217        let user_key: SymmetricCryptoKey = USER_KEY.to_string().try_into().unwrap();
218        let store = create_test_crypto_with_user_key(user_key);
219        let mut ctx = store.context();
220        let raw = B64Url::try_from(URL_KEY).unwrap().into_bytes();
221        let key = Send::derive_shareable_key(&mut ctx, &raw).unwrap();
222        let encrypted = OctetStreamBytes::from(plaintext.clone())
223            .encrypt(&mut ctx, key)
224            .unwrap()
225            .to_buffer()
226            .unwrap();
227
228        let decrypted = client()
229            .decrypt_send_access_file(URL_KEY.to_owned(), &encrypted)
230            .expect("decrypts");
231
232        assert_eq!(decrypted, plaintext);
233    }
234
235    #[test]
236    fn decrypt_send_access_file_rejects_malformed_key() {
237        let err = client()
238            .decrypt_send_access_file("not valid base64!".to_owned(), b"irrelevant")
239            .unwrap_err();
240
241        assert!(matches!(
242            err,
243            SendAccessDecryptError::Key(SendAccessKeyError::InvalidEncoding)
244        ));
245    }
246
247    #[test]
248    fn decrypt_send_access_file_errors_on_garbage_blob() {
249        let err = client()
250            .decrypt_send_access_file(URL_KEY.to_owned(), b"not an encstring")
251            .unwrap_err();
252
253        assert!(matches!(err, SendAccessDecryptError::Crypto(_)));
254    }
255
256    // ===== Network calls go to the configured instance =====
257
258    use bitwarden_auth::send_access::{
259        SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest,
260        SendPasswordCredentials,
261    };
262    use wiremock::{
263        Mock, MockServer, ResponseTemplate,
264        matchers::{body_string_contains, method, path},
265    };
266
267    fn client_for(server: &MockServer) -> SendReceiveClient {
268        SendReceiveClient::new(Some(ClientSettings {
269            api_url: format!("{}/api", server.uri()),
270            identity_url: format!("{}/identity", server.uri()),
271            ..Default::default()
272        }))
273    }
274
275    #[tokio::test]
276    async fn access_send_calls_the_configured_api_with_the_token() {
277        let server = MockServer::start().await;
278        Mock::given(method("POST"))
279            .and(path("/api/sends/access"))
280            .and(wiremock::matchers::header(
281                "authorization",
282                "Bearer the-token",
283            ))
284            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
285                "id": "access-id",
286                "type": 0,
287                "name": "encrypted-name",
288                "text": { "text": "encrypted-text", "hidden": false },
289            })))
290            .expect(1)
291            .mount(&server)
292            .await;
293
294        let response = client_for(&server)
295            .access_send("the-token".to_owned())
296            .await
297            .expect("access succeeds");
298
299        assert_eq!(response.id.as_deref(), Some("access-id"));
300    }
301
302    #[tokio::test]
303    async fn get_file_download_data_calls_the_configured_api() {
304        let server = MockServer::start().await;
305        Mock::given(method("POST"))
306            .and(path("/api/sends/access/file/file-id"))
307            .and(wiremock::matchers::header(
308                "authorization",
309                "Bearer the-token",
310            ))
311            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
312                "id": "file-id",
313                "url": "https://files.example.invalid/blob",
314            })))
315            .expect(1)
316            .mount(&server)
317            .await;
318
319        let data = client_for(&server)
320            .get_file_download_data("the-token".to_owned(), "file-id".to_owned())
321            .await
322            .expect("download data resolves");
323
324        assert_eq!(
325            data.url.as_deref(),
326            Some("https://files.example.invalid/blob")
327        );
328    }
329
330    #[tokio::test]
331    async fn request_send_access_token_goes_to_the_configured_identity_server() {
332        let server = MockServer::start().await;
333        Mock::given(method("POST"))
334            .and(path("/identity/connect/token"))
335            .and(body_string_contains("send_id=send-id"))
336            .and(body_string_contains("password_hash_b64=hash"))
337            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
338                "access_token": "minted-token",
339                "token_type": "bearer",
340                "expires_in": 3600,
341                "scope": "api.send.access",
342            })))
343            .expect(1)
344            .mount(&server)
345            .await;
346
347        let token = client_for(&server)
348            .request_send_access_token(SendAccessTokenRequest {
349                send_id: "send-id".to_owned(),
350                send_access_credentials: Some(SendAccessCredentials::Password(
351                    SendPasswordCredentials {
352                        password_hash_b64: "hash".to_owned(),
353                    },
354                )),
355            })
356            .await
357            .expect("token is minted");
358
359        assert_eq!(token.token, "minted-token");
360    }
361
362    #[tokio::test]
363    async fn requests_carry_the_configured_device_identifier_and_user_agent() {
364        let server = MockServer::start().await;
365        Mock::given(method("POST"))
366            .and(path("/identity/connect/token"))
367            .and(wiremock::matchers::header(
368                "device-identifier",
369                "device-123",
370            ))
371            .and(wiremock::matchers::header(
372                "user-agent",
373                "Bitwarden_CLI/test",
374            ))
375            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
376                "access_token": "minted-token",
377                "token_type": "bearer",
378                "expires_in": 3600,
379                "scope": "api.send.access",
380            })))
381            .expect(1)
382            .mount(&server)
383            .await;
384
385        let client = SendReceiveClient::new(Some(ClientSettings {
386            api_url: format!("{}/api", server.uri()),
387            identity_url: format!("{}/identity", server.uri()),
388            user_agent: "Bitwarden_CLI/test".to_owned(),
389            device_identifier: Some("device-123".to_owned()),
390            ..Default::default()
391        }));
392
393        client
394            .request_send_access_token(SendAccessTokenRequest {
395                send_id: "send-id".to_owned(),
396                send_access_credentials: None,
397            })
398            .await
399            .expect("token is minted");
400    }
401
402    #[tokio::test]
403    async fn request_send_access_token_surfaces_server_rejection() {
404        let server = MockServer::start().await;
405        Mock::given(method("POST"))
406            .and(path("/identity/connect/token"))
407            .respond_with(ResponseTemplate::new(500))
408            .mount(&server)
409            .await;
410
411        let err = client_for(&server)
412            .request_send_access_token(SendAccessTokenRequest {
413                send_id: "send-id".to_owned(),
414                send_access_credentials: None,
415            })
416            .await
417            .unwrap_err();
418
419        assert!(matches!(err, SendAccessTokenError::Unexpected(_)));
420    }
421
422    #[test]
423    fn hash_send_password_matches_send_access_key() {
424        let expected = SendAccessKey::from_url_b64(URL_KEY)
425            .unwrap()
426            .hash_password_b64("hunter2");
427
428        let hash = client()
429            .hash_send_password(URL_KEY.to_owned(), "hunter2".to_owned())
430            .expect("hashes");
431
432        assert_eq!(hash, expected);
433    }
434
435    #[test]
436    fn hash_send_password_rejects_malformed_key() {
437        let err = client()
438            .hash_send_password("not valid base64!".to_owned(), "hunter2".to_owned())
439            .unwrap_err();
440
441        assert!(matches!(err, SendAccessKeyError::InvalidEncoding));
442    }
443}