1use bitwarden_auth::send_access::{
15 SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest, SendEmailCredentials,
16 SendEmailOtpCredentials, SendPasswordCredentials,
17 api::{
18 SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError,
19 SendAccessTokenInvalidRequestError,
20 },
21};
22use bitwarden_core::get_host_platform_info;
23use bitwarden_pm::PasswordManagerClient;
24use bitwarden_send::{SendAccessKey, SendAccessView, SendType};
25use color_eyre::eyre::{Context as _, Result, bail, eyre};
26use inquire::{Password, Text, validator::Validation};
27use url::Url;
28
29use crate::{
30 platform::{ConfigFile, read_config_json},
31 render::{CommandOutput, CommandResult},
32 tools::file_output::{default_send_file_name, reject_path_traversal, save_file},
33};
34
35const CLOUD_HOSTS: &[CloudRegion] = &[
42 CloudRegion {
43 hosts: &["send.bitwarden.com", "vault.bitwarden.com"],
44 api_url: "https://api.bitwarden.com",
45 identity_url: "https://identity.bitwarden.com",
46 },
47 CloudRegion {
48 hosts: &["send.bitwarden.eu", "vault.bitwarden.eu"],
49 api_url: "https://api.bitwarden.eu",
50 identity_url: "https://identity.bitwarden.eu",
51 },
52];
53
54struct CloudRegion {
55 hosts: &'static [&'static str],
56 api_url: &'static str,
57 identity_url: &'static str,
58}
59
60pub(crate) struct ReceiveInputs {
63 pub url: String,
65 pub password: Option<String>,
67 pub passwordenv: Option<String>,
69 pub passwordfile: Option<String>,
71 pub output_path: Option<String>,
77 pub full_object: bool,
79}
80
81pub(crate) async fn run_receive(inputs: ReceiveInputs) -> CommandResult {
83 let url = Url::parse(&inputs.url).wrap_err("Failed to parse the provided Send url")?;
84 let (send_id, key_b64) = parse_send_url(&url)?;
85
86 let access_key = SendAccessKey::from_url_b64(&key_b64)
90 .map_err(|_| eyre!("Failed to parse url, the url provided is not a valid Send url"))?;
91
92 let (api_url, identity_url) = resolve_urls(&url, read_config_json().ok().flatten().as_ref());
93 let client = PasswordManagerClient::new(Some(
94 get_host_platform_info().to_client_settings(api_url, identity_url),
95 ));
96
97 let token = attempt_access(&client, &send_id, &access_key, &inputs).await?;
98 render_access(&client, &access_key, token, &inputs).await
99}
100
101fn parse_send_url(url: &Url) -> Result<(String, String)> {
108 let fragment = url.fragment().unwrap_or_default();
109 let mut trailing = fragment.rsplit('/');
110 let key = trailing.next().unwrap_or_default();
111 let id = trailing.next().unwrap_or_default();
112
113 if id.trim().is_empty() || key.trim().is_empty() {
114 return Err(eyre!(
115 "Failed to parse url, the url provided is not a valid Send url"
116 ));
117 }
118
119 Ok((id.to_string(), key.to_string()))
120}
121
122fn resolve_urls(url: &Url, config: Option<&ConfigFile>) -> (String, String) {
135 if let Some(host) = url.host_str()
136 && let Some(region) = CLOUD_HOSTS
137 .iter()
138 .find(|region| region.hosts.contains(&host))
139 {
140 return (region.api_url.to_string(), region.identity_url.to_string());
141 }
142
143 let origin = url.origin().ascii_serialization();
144
145 if let Some(config) = config
146 && let Some(base) = [config.web_vault.as_deref(), config.server.as_deref()]
147 .into_iter()
148 .flatten()
149 .find(|configured| same_origin(configured, &origin))
150 {
151 let base = base.trim_end_matches('/');
152 let api = trimmed(config.api.as_deref()).unwrap_or_else(|| format!("{base}/api"));
153 let identity =
154 trimmed(config.identity.as_deref()).unwrap_or_else(|| format!("{base}/identity"));
155 return (api, identity);
156 }
157
158 (format!("{origin}/api"), format!("{origin}/identity"))
159}
160
161fn same_origin(configured: &str, origin: &str) -> bool {
165 match Url::parse(configured) {
166 Ok(parsed) => parsed.origin().ascii_serialization() == origin,
167 Err(_) => configured.trim_end_matches('/') == origin,
168 }
169}
170
171fn trimmed(value: Option<&str>) -> Option<String> {
172 value
173 .map(|v| v.trim_end_matches('/'))
174 .filter(|v| !v.is_empty())
175 .map(str::to_string)
176}
177
178async fn attempt_access(
187 client: &PasswordManagerClient,
188 send_id: &str,
189 access_key: &SendAccessKey,
190 inputs: &ReceiveInputs,
191) -> Result<String> {
192 match request_token(client, send_id, None).await {
193 Ok(token) => Ok(token),
194 Err(err) => match invalid_request_type(&err) {
195 Some(SendAccessTokenInvalidRequestError::PasswordHashB64Required) => {
196 access_with_password(client, send_id, access_key, inputs).await
197 }
198 Some(SendAccessTokenInvalidRequestError::EmailRequired) => {
199 access_with_email_otp(client, send_id).await
200 }
201 _ if invalid_grant_type(&err)
202 == Some(&SendAccessTokenInvalidGrantError::SendIdInvalid) =>
203 {
204 Err(eyre!("Not found."))
205 }
206 _ => Err(token_error(err)),
207 },
208 }
209}
210
211async fn access_with_password(
214 client: &PasswordManagerClient,
215 send_id: &str,
216 access_key: &SendAccessKey,
217 inputs: &ReceiveInputs,
218) -> Result<String> {
219 let password = resolve_password(inputs)?;
220 let credentials = SendAccessCredentials::Password(SendPasswordCredentials {
221 password_hash_b64: access_key.hash_password_b64(&password),
222 });
223
224 match request_token(client, send_id, Some(credentials)).await {
225 Ok(token) => Ok(token),
226 Err(err)
227 if invalid_grant_type(&err)
228 == Some(&SendAccessTokenInvalidGrantError::PasswordHashB64Invalid) =>
229 {
230 Err(eyre!("Invalid password"))
231 }
232 Err(err) => Err(token_error(err)),
233 }
234}
235
236async fn access_with_email_otp(client: &PasswordManagerClient, send_id: &str) -> Result<String> {
239 if !can_interact() {
240 return Err(eyre!(
241 "Email verification required. Run in interactive mode."
242 ));
243 }
244
245 let email = prompt_email()?;
246 let credentials = SendAccessCredentials::Email(SendEmailCredentials {
247 email: email.clone(),
248 });
249
250 let err = match request_token(client, send_id, Some(credentials)).await {
251 Ok(_) => return Err(eyre!("Unexpected server response")),
254 Err(err) => err,
255 };
256
257 if invalid_request_type(&err) != Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired)
258 {
259 return Err(token_error(err));
260 }
261
262 let otp = prompt_otp()?;
263 let credentials = SendAccessCredentials::EmailOtp(SendEmailOtpCredentials { email, otp });
264
265 match request_token(client, send_id, Some(credentials)).await {
266 Ok(token) => Ok(token),
267 Err(err)
269 if invalid_request_type(&err)
270 == Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired) =>
271 {
272 Err(eyre!("Invalid email or verification code"))
273 }
274 Err(err) => Err(token_error(err)),
275 }
276}
277
278async fn request_token(
279 client: &PasswordManagerClient,
280 send_id: &str,
281 credentials: Option<SendAccessCredentials>,
282) -> Result<String, SendAccessTokenError> {
283 client
284 .auth()
285 .send_access()
286 .request_send_access_token(SendAccessTokenRequest {
287 send_id: send_id.to_string(),
288 send_access_credentials: credentials,
289 })
290 .await
291 .map(|response| response.token)
292}
293
294fn invalid_request_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidRequestError> {
296 match err {
297 SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidRequest {
298 send_access_error_type,
299 ..
300 }) => send_access_error_type.as_ref(),
301 _ => None,
302 }
303}
304
305fn invalid_grant_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidGrantError> {
307 match err {
308 SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidGrant {
309 send_access_error_type,
310 ..
311 }) => send_access_error_type.as_ref(),
312 _ => None,
313 }
314}
315
316fn token_error(err: SendAccessTokenError) -> color_eyre::eyre::Error {
320 match err {
321 SendAccessTokenError::Unexpected(inner) => eyre!("Server error: {inner:?}"),
322 SendAccessTokenError::Expected(inner) => eyre!("Error: {inner:?}"),
323 }
324}
325
326async fn render_access(
328 client: &PasswordManagerClient,
329 access_key: &SendAccessKey,
330 token: String,
331 inputs: &ReceiveInputs,
332) -> CommandResult {
333 let response = client.sends().access_send(token.clone()).await?;
334 let view = access_key.decrypt_response(response)?;
335
336 if inputs.full_object {
337 return Ok(CommandOutput::Object(Box::new(view)));
338 }
339
340 match view.type_ {
341 Some(SendType::Text) => Ok(CommandOutput::Plain(
345 view.text.and_then(|text| text.text).unwrap_or_default(),
346 )),
347 Some(SendType::File) => {
348 save_file_send(
349 client,
350 access_key,
351 &token,
352 view,
353 inputs.output_path.as_deref(),
354 )
355 .await
356 }
357 Some(SendType::Item) => {
358 bail!("Accessing item Sends is not supported by the CLI.");
359 }
360 None => Ok(CommandOutput::Object(Box::new(view))),
363 }
364}
365
366async fn save_file_send(
368 client: &PasswordManagerClient,
369 access_key: &SendAccessKey,
370 token: &str,
371 view: SendAccessView,
372 output: Option<&str>,
373) -> CommandResult {
374 let file = view
375 .file
376 .ok_or_else(|| eyre!("The Send is a file Send but carries no file metadata."))?;
377 let file_id = file
378 .id
379 .ok_or_else(|| eyre!("The Send's file is missing an id; cannot download it."))?;
380
381 let download = client
382 .sends()
383 .get_file_download_data(token.to_string(), file_id)
384 .await?;
385 let download_url = download
386 .url
387 .ok_or_else(|| eyre!("The server did not return a download url for the Send's file."))?;
388
389 let encrypted = download_bytes(client, &download_url).await?;
390 let decrypted = access_key.decrypt_file_buffer(&encrypted)?;
391
392 let file_name = default_send_file_name(file.file_name.as_deref());
393 let path = save_file(output, &file_name, &decrypted)?;
394
395 Ok(CommandOutput::Plain(format!("Saved {}", path.display())))
396}
397
398async fn download_bytes(client: &PasswordManagerClient, url: &str) -> Result<Vec<u8>> {
405 let response = client
406 .0
407 .internal
408 .get_http_client()
409 .get(url)
410 .header("cache-control", "no-cache")
412 .send()
413 .await?;
414
415 if !response.status().is_success() {
416 return Err(eyre!(
417 "A {} error occurred while downloading the attachment.",
418 response.status().as_u16()
419 ));
420 }
421
422 Ok(response.bytes().await?.to_vec())
423}
424
425fn resolve_password(inputs: &ReceiveInputs) -> Result<String> {
430 let supplied = password_from_args(
431 inputs.password.as_deref(),
432 inputs.passwordfile.as_deref(),
433 inputs.passwordenv.as_deref(),
434 )?;
435
436 let password = match supplied {
437 Some(password) => password,
438 None if can_interact() => prompt_password()?,
439 None => return Err(eyre!("Password required")),
440 };
441
442 if password.is_empty() {
443 return Err(eyre!("Password required"));
444 }
445
446 Ok(password)
447}
448
449fn password_from_args(
456 password: Option<&str>,
457 passwordfile: Option<&str>,
458 passwordenv: Option<&str>,
459) -> Result<Option<String>> {
460 if let Some(password) = password.filter(|p| !p.is_empty()) {
461 return Ok(Some(password.to_string()));
462 }
463
464 if let Some(path) = passwordfile {
465 reject_path_traversal("--passwordfile", path)?;
466 let contents = std::fs::read_to_string(path)
467 .wrap_err_with(|| format!("Could not read --passwordfile {path}"))?;
468 return Ok(contents.lines().next().map(str::to_string));
472 }
473
474 if let Some(name) = passwordenv {
475 return match std::env::var(name) {
476 Ok(value) => Ok(Some(value)),
477 Err(_) => {
480 tracing::warn!("--passwordenv variable `{name}` is not set; ignoring it.");
481 Ok(None)
482 }
483 };
484 }
485
486 Ok(None)
487}
488
489fn can_interact() -> bool {
498 std::env::var("BW_NOINTERACTION").as_deref() != Ok("true")
499}
500
501fn prompt_password() -> Result<String> {
505 Ok(Password::new("Send password")
506 .without_confirmation()
507 .prompt()?)
508}
509
510fn prompt_email() -> Result<String> {
511 Ok(Text::new("Enter your email address:")
512 .with_validator(|input: &str| {
513 if input.contains('@') {
514 Ok(Validation::Valid)
515 } else {
516 Ok(Validation::Invalid(
517 "Please enter a valid email address".into(),
518 ))
519 }
520 })
521 .prompt()?)
522}
523
524fn prompt_otp() -> Result<String> {
525 Ok(Text::new("Enter the verification code sent to your email:").prompt()?)
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn parse(url: &str) -> Url {
533 Url::parse(url).expect("test url parses")
534 }
535
536 #[test]
539 fn parses_the_web_vault_send_route() {
540 let (id, key) = parse_send_url(&parse(
541 "https://vault.bitwarden.com/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
542 ))
543 .unwrap();
544 assert_eq!(id, "access-id");
545 assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
546 }
547
548 #[test]
549 fn parses_the_send_vanity_host_route() {
550 let (id, key) = parse_send_url(&parse(
552 "https://send.bitwarden.com/#access-id/Pgui0FK85cNhBGWHAlBHBw",
553 ))
554 .unwrap();
555 assert_eq!(id, "access-id");
556 assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
557 }
558
559 #[test]
560 fn parses_self_hosted_links_with_a_path_prefix() {
561 let (id, key) = parse_send_url(&parse(
562 "https://example.com/bitwarden/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
563 ))
564 .unwrap();
565 assert_eq!(id, "access-id");
566 assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
567 }
568
569 #[test]
570 fn rejects_a_url_without_a_fragment() {
571 let err = parse_send_url(&parse("https://vault.bitwarden.com/")).unwrap_err();
572 assert!(
573 err.to_string().contains("not a valid Send url"),
574 "got: {err}"
575 );
576 }
577
578 #[test]
579 fn rejects_a_fragment_with_only_one_segment() {
580 let err = parse_send_url(&parse("https://send.bitwarden.com/#access-id")).unwrap_err();
581 assert!(
582 err.to_string().contains("not a valid Send url"),
583 "got: {err}"
584 );
585 }
586
587 #[test]
588 fn rejects_a_fragment_with_a_blank_segment() {
589 assert!(
591 parse_send_url(&parse("https://vault.bitwarden.com/#/send//key")).is_err(),
592 "blank id must be rejected"
593 );
594 assert!(
595 parse_send_url(&parse("https://vault.bitwarden.com/#/send/id/")).is_err(),
596 "blank key must be rejected"
597 );
598 }
599
600 #[test]
603 fn cloud_send_and_vault_hosts_map_to_their_region() {
604 for host in ["send.bitwarden.com", "vault.bitwarden.com"] {
605 let (api, identity) = resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
606 assert_eq!(api, "https://api.bitwarden.com");
607 assert_eq!(identity, "https://identity.bitwarden.com");
608 }
609 for host in ["send.bitwarden.eu", "vault.bitwarden.eu"] {
610 let (api, identity) = resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
611 assert_eq!(api, "https://api.bitwarden.eu");
612 assert_eq!(identity, "https://identity.bitwarden.eu");
613 }
614 }
615
616 #[test]
620 fn a_lookalike_host_is_never_treated_as_cloud() {
621 for host in [
622 "send.bitwarden.com.evil.tld",
623 "evil-send.bitwarden.com.attacker.example",
624 "notsend.bitwarden.com.evil.tld",
625 ] {
626 let (api, identity) = resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
627 assert_eq!(api, format!("https://{host}/api"));
628 assert_eq!(identity, format!("https://{host}/identity"));
629 }
630 }
631
632 #[test]
633 fn unknown_host_falls_back_to_the_single_domain_convention() {
634 let (api, identity) = resolve_urls(&parse("https://vault.example.com/#/send/id/key"), None);
635 assert_eq!(api, "https://vault.example.com/api");
636 assert_eq!(identity, "https://vault.example.com/identity");
637 }
638
639 #[test]
640 fn a_non_default_port_is_preserved_in_the_fallback() {
641 let (api, identity) = resolve_urls(&parse("https://localhost:8080/#/send/id/key"), None);
642 assert_eq!(api, "https://localhost:8080/api");
643 assert_eq!(identity, "https://localhost:8080/identity");
644 }
645
646 #[test]
647 fn a_matching_configured_server_supplies_the_base() {
648 let config = ConfigFile {
649 server: Some("https://bw.example.com".to_string()),
650 ..Default::default()
651 };
652 let (api, identity) = resolve_urls(
653 &parse("https://bw.example.com/#/send/id/key"),
654 Some(&config),
655 );
656 assert_eq!(api, "https://bw.example.com/api");
657 assert_eq!(identity, "https://bw.example.com/identity");
658 }
659
660 #[test]
661 fn a_matching_configured_web_vault_supplies_the_base() {
662 let config = ConfigFile {
663 web_vault: Some("https://vault.example.com/".to_string()),
664 ..Default::default()
665 };
666 let (api, identity) = resolve_urls(
667 &parse("https://vault.example.com/#/send/id/key"),
668 Some(&config),
669 );
670 assert_eq!(api, "https://vault.example.com/api");
671 assert_eq!(identity, "https://vault.example.com/identity");
672 }
673
674 #[test]
677 fn explicit_configured_api_and_identity_win_over_the_base() {
678 let config = ConfigFile {
679 web_vault: Some("https://vault.example.com".to_string()),
680 api: Some("https://api.example.com".to_string()),
681 identity: Some("https://identity.example.com/".to_string()),
682 ..Default::default()
683 };
684 let (api, identity) = resolve_urls(
685 &parse("https://vault.example.com/#/send/id/key"),
686 Some(&config),
687 );
688 assert_eq!(api, "https://api.example.com");
689 assert_eq!(identity, "https://identity.example.com");
690 }
691
692 #[test]
695 fn a_config_for_a_different_origin_is_ignored() {
696 let config = ConfigFile {
697 server: Some("https://bw.example.com".to_string()),
698 api: Some("https://api.example.com".to_string()),
699 ..Default::default()
700 };
701 let (api, identity) =
702 resolve_urls(&parse("https://send.bitwarden.com/#id/key"), Some(&config));
703 assert_eq!(api, "https://api.bitwarden.com");
704 assert_eq!(identity, "https://identity.bitwarden.com");
705
706 let (api, identity) = resolve_urls(
707 &parse("https://other.example.com/#/send/id/key"),
708 Some(&config),
709 );
710 assert_eq!(api, "https://other.example.com/api");
711 assert_eq!(identity, "https://other.example.com/identity");
712 }
713
714 #[test]
717 fn password_flag_wins() {
718 let password = password_from_args(Some("hunter2"), None, None).unwrap();
719 assert_eq!(password.as_deref(), Some("hunter2"));
720 }
721
722 #[test]
723 fn empty_password_flag_falls_through() {
724 assert_eq!(password_from_args(Some(""), None, None).unwrap(), None);
726 }
727
728 #[test]
729 fn password_file_supplies_its_first_line_only() {
730 let path = std::env::temp_dir().join(format!(
731 "bw-receive-passwordfile-{}",
732 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
733 ));
734 std::fs::write(&path, "first-line\nsecond-line\n").unwrap();
735
736 let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
737
738 assert_eq!(password.as_deref(), Some("first-line"));
739 std::fs::remove_file(&path).ok();
740 }
741
742 #[test]
743 fn password_file_strips_a_windows_line_terminator() {
744 let path = std::env::temp_dir().join(format!(
745 "bw-receive-passwordfile-crlf-{}",
746 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
747 ));
748 std::fs::write(&path, "first-line\r\nsecond\r\n").unwrap();
749
750 let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
751
752 assert_eq!(password.as_deref(), Some("first-line"));
753 std::fs::remove_file(&path).ok();
754 }
755
756 #[test]
757 fn password_file_rejects_traversal() {
758 let err = password_from_args(None, Some("../secrets/pw.txt"), None).unwrap_err();
759 assert!(
760 err.to_string().contains("--passwordfile"),
761 "expected a --passwordfile traversal error, got: {err}"
762 );
763 }
764
765 #[test]
766 fn missing_password_file_is_an_error_not_a_fallthrough() {
767 assert!(password_from_args(None, Some("/nonexistent/bw-receive-pw.txt"), None).is_err());
770 }
771
772 #[test]
775 fn password_file_short_circuits_password_env() {
776 let path = std::env::temp_dir().join(format!(
777 "bw-receive-passwordfile-empty-{}",
778 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
779 ));
780 std::fs::write(&path, "").unwrap();
781
782 let password = password_from_args(
783 None,
784 Some(&path.to_string_lossy()),
785 Some("BW_RECEIVE_TEST_UNSET_VAR"),
789 )
790 .unwrap();
791
792 assert_eq!(password, None);
793 std::fs::remove_file(&path).ok();
794 }
795
796 #[test]
797 fn unset_password_env_yields_no_password() {
798 assert_eq!(
799 password_from_args(None, None, Some("BW_RECEIVE_TEST_DEFINITELY_UNSET")).unwrap(),
800 None
801 );
802 }
803
804 #[test]
805 fn no_inputs_yields_no_password() {
806 assert_eq!(password_from_args(None, None, None).unwrap(), None);
807 }
808}