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#[bitwarden_ffi::wasm_object]
22pub struct SendReceiveClient {
23 client: Client,
24}
25
26#[bitwarden_ffi::wasm_export]
27impl SendReceiveClient {
28 #[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 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 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 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 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 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 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 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 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 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 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}