diff --git a/src/commands/setup.rs b/src/commands/setup.rs index b5af39bf..f609c66c 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -2,7 +2,7 @@ use log::*; use phonenumber::PhoneNumber; use secrecy::ExposeSecret; use steamguard::{ - accountlinker::{AccountLinkConfirmType, AccountLinkSuccess}, + accountlinker::{AccountLinkConfirmType, AccountLinkSuccess, RemoveAuthenticatorError}, phonelinker::PhoneLinker, steamapi::PhoneClient, token::Tokens, @@ -43,12 +43,10 @@ where info!("Adding authenticator..."); let mut linker = AccountLinker::new(transport.clone(), tokens); - let link: AccountLinkSuccess; loop { match linker.link() { - Ok(a) => { - link = a; - break; + Ok(link) => { + return Self::add_new_account(link, manager, account_name, linker); } Err(AccountLinkError::MustProvidePhoneNumber) => { // As of Dec 12, 2023, Steam no longer appears to require a phone number to add an authenticator. Keeping this code here just in case. @@ -59,6 +57,49 @@ where println!("Check your email and click the link."); tui::pause(); } + Err(AccountLinkError::AuthenticatorPresent) => { + eprintln!("It looks like there's already an authenticator on this account. If you want to link it to steamguard-cli, you'll need to remove it first. If you remove it using your revocation code (R#####), you'll get a 15 day trade ban."); + eprintln!("However, you can \"transfer\" the authenticator to steamguard-cli if you have access to the phone number associated with your account. This will cause you to get only a 2 day trade ban."); + eprintln!("If you were using SDA or WinAuth, you can import it into steamguard-cli with the `import` command, and have no trade ban."); + eprintln!("You can't have the same authenticator on steamguard-cli and the steam mobile app at the same time."); + + eprintln!("\nHere are your options:"); + eprintln!("[T] Transfer authenticator to steamguard-cli (2 day trade ban)"); + eprintln!("[R] Revoke authenticator with revocation code (15 day trade ban)"); + eprintln!("[A] Abort setup"); + let answer = tui::prompt_char("What would you like to do?", "Tra"); + match answer { + 't' => return Self::transfer_new_account(linker, manager), + 'r' => { + loop { + let revocation_code = + tui::prompt_non_empty("Enter your revocation code (R#####): "); + match linker.remove_authenticator(Some(&revocation_code)) { + Ok(_) => break, + Err(RemoveAuthenticatorError::IncorrectRevocationCode { + attempts_remaining, + }) => { + error!( + "Revocation code was incorrect ({} attempts remaining)", + attempts_remaining + ); + if attempts_remaining == 0 { + error!("No attempts remaining, aborting!"); + bail!("Failed to remove authenticator: no attempts remaining") + } + } + Err(err) => { + error!("Failed to remove authenticator: {}", err); + } + } + } + } + _ => { + info!("Aborting account linking."); + return Err(AccountLinkError::AuthenticatorPresent.into()); + } + } + } Err(err) => { error!( "Failed to link authenticator. Account has not been linked. {}", @@ -68,6 +109,20 @@ where } } } + } +} + +impl SetupCommand { + /// Add a new account to the manifest after linking has started. + fn add_new_account( + link: AccountLinkSuccess, + manager: &mut AccountManager, + account_name: String, + mut linker: AccountLinker, + ) -> Result<(), anyhow::Error> + where + T: Transport + Clone, + { let mut server_time = link.server_time(); let phone_number_hint = link.phone_number_hint().to_owned(); let confirm_type = link.confirm_type(); @@ -77,21 +132,18 @@ where Err(err) => { error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", err); eprintln!( - "Just in case, here is the account info. Save it somewhere just in case!\n{:#?}", - manager.get_account(&account_name).unwrap().lock().unwrap() - ); + "Just in case, here is the account info. Save it somewhere just in case!\n{:#?}", + manager.get_account(&account_name).unwrap().lock().unwrap() + ); return Err(err); } } - let account_arc = manager .get_account(&account_name) .expect("account was not present in manifest"); let mut account = account_arc.lock().unwrap(); - eprintln!("Authenticator has not yet been linked. Before continuing with finalization, please take the time to write down your revocation code: {}", account.revocation_code.expose_secret()); tui::pause(); - debug!("attempting link finalization"); let confirm_code = match confirm_type { AccountLinkConfirmType::Email => { @@ -112,7 +164,6 @@ where bail!("Unknown link confirm type: {}", t); } }; - let mut tries = 0; loop { match linker.finalize(server_time, &mut account, confirm_code.clone()) { @@ -133,8 +184,7 @@ where } } let revocation_code = account.revocation_code.clone(); - drop(account); // explicitly drop the lock so we don't hang on the mutex - + drop(account); info!("Verifying authenticator status..."); let status = linker.query_status(&manager.get_account(&account_name).unwrap().lock().unwrap())?; @@ -147,7 +197,6 @@ where manager.save()?; bail!("Authenticator finalization was unsuccessful. You may have entered the wrong confirm code in the previous step. Try again."); } - info!("Authenticator finalized."); match manager.save() { Ok(_) => {} @@ -159,12 +208,52 @@ where return Err(err); } } - eprintln!( "Authenticator has been finalized. Please actually write down your revocation code: {}", revocation_code.expose_secret() ); + Ok(()) + } + + /// Transfer an existing authenticator to steamguard-cli. + fn transfer_new_account( + mut linker: AccountLinker, + manager: &mut AccountManager, + ) -> anyhow::Result<()> + where + T: Transport + Clone, + { + info!("Transferring authenticator to steamguard-cli"); + linker.transfer_start()?; + + let account: SteamGuardAccount; + loop { + let sms_code = tui::prompt_non_empty("Enter SMS code: "); + match linker.transfer_finish(sms_code) { + Ok(acc) => { + account = acc; + break; + } + Err(err) => { + error!("Failed to transfer authenticator: {}", err); + } + } + } + info!("Transfer successful, adding account to manifest"); + let revocation_code = account.revocation_code.clone(); + eprintln!( + "Take a moment to write down your revocation code: {}", + revocation_code.expose_secret() + ); + + manager.add_account(account); + manager.save()?; + + eprintln!( + "Make sure you have your revocation code written down: {}", + revocation_code.expose_secret() + ); Ok(()) } } diff --git a/steamguard/src/accountlinker.rs b/steamguard/src/accountlinker.rs index a7b39756..7511a335 100644 --- a/steamguard/src/accountlinker.rs +++ b/steamguard/src/accountlinker.rs @@ -1,5 +1,7 @@ use crate::protobufs::service_twofactor::{ CTwoFactor_AddAuthenticator_Request, CTwoFactor_FinalizeAddAuthenticator_Request, + CTwoFactor_RemoveAuthenticatorViaChallengeContinue_Request, + CTwoFactor_RemoveAuthenticatorViaChallengeStart_Request, CTwoFactor_RemoveAuthenticator_Request, CTwoFactor_Status_Request, CTwoFactor_Status_Response, }; use crate::steamapi::twofactor::TwoFactorClient; @@ -172,6 +174,63 @@ where Ok(()) } + + /// Begin the process of "transfering" a mobile authenticator from a different device to this device. + /// + /// "Transfering" does not actually literally transfer the secrets from one device to another. Instead, it generates a new set of secrets on this device, and invalidates the old secrets on the other device. Call [`Self::transfer_finish`] to complete the process. + pub fn transfer_start(&mut self) -> Result<(), TransferError> { + let req = CTwoFactor_RemoveAuthenticatorViaChallengeStart_Request::new(); + let resp = self + .client + .remove_authenticator_via_challenge_start(req, self.tokens().access_token())?; + if resp.result != EResult::OK { + return Err(resp.result.into()); + } + // the success field in the response is always None, so we can't check that + // it appears to not be used at all + Ok(()) + } + + /// Completes the process of "transfering" a mobile authenticator from a different device to this device. + pub fn transfer_finish( + &mut self, + sms_code: impl AsRef, + ) -> Result { + let access_token = self.tokens.access_token(); + let steam_id = access_token + .decode() + .context("decoding access token")? + .steam_id(); + let mut req = CTwoFactor_RemoveAuthenticatorViaChallengeContinue_Request::new(); + req.set_sms_code(sms_code.as_ref().to_owned()); + req.set_generate_new_token(true); + let resp = self + .client + .remove_authenticator_via_challenge_continue(req, access_token)?; + if resp.result != EResult::OK { + return Err(resp.result.into()); + } + let resp = resp.into_response_data(); + let mut resp = resp.replacement_token.clone().unwrap(); + let account = SteamGuardAccount { + account_name: resp.take_account_name(), + steam_id, + serial_number: resp.serial_number().to_string(), + revocation_code: resp.take_revocation_code().into(), + uri: resp.take_uri().into(), + shared_secret: TwoFactorSecret::from_bytes(resp.take_shared_secret()), + token_gid: resp.take_token_gid(), + identity_secret: base64::engine::general_purpose::STANDARD + .encode(resp.take_identity_secret()) + .into(), + device_id: self.device_id.clone(), + secret_1: base64::engine::general_purpose::STANDARD + .encode(resp.take_secret_1()) + .into(), + tokens: Some(self.tokens.clone()), + }; + Ok(account) + } } #[derive(Debug)] @@ -303,3 +362,24 @@ impl From for RemoveAuthenticatorError { Self::UnknownEResult(e) } } + +#[derive(Error, Debug)] +pub enum TransferError { + #[error("Provided SMS code was incorrect.")] + BadSmsCode, + #[error("Failed to send request to Steam: {0:?}")] + Transport(#[from] crate::transport::TransportError), + #[error("Steam returned an unexpected error code: {0:?}")] + UnknownEResult(EResult), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +impl From for TransferError { + fn from(result: EResult) -> Self { + match result { + EResult::SMSCodeFailed => TransferError::BadSmsCode, + r => TransferError::UnknownEResult(r), + } + } +} diff --git a/steamguard/src/steamapi/twofactor.rs b/steamguard/src/steamapi/twofactor.rs index 51b04745..0eef243d 100644 --- a/steamguard/src/steamapi/twofactor.rs +++ b/steamguard/src/steamapi/twofactor.rs @@ -69,6 +69,45 @@ where Ok(resp) } + pub fn remove_authenticator_via_challenge_start( + &self, + req: CTwoFactor_RemoveAuthenticatorViaChallengeStart_Request, + access_token: &Jwt, + ) -> Result, TransportError> + { + let req = ApiRequest::new(SERVICE_NAME, "RemoveAuthenticatorViaChallengeStart", 1, req) + .with_access_token(access_token); + let resp = self + .transport + .send_request::( + req, + )?; + Ok(resp) + } + + pub fn remove_authenticator_via_challenge_continue( + &self, + req: CTwoFactor_RemoveAuthenticatorViaChallengeContinue_Request, + access_token: &Jwt, + ) -> Result< + ApiResponse, + TransportError, + > { + let req = ApiRequest::new( + SERVICE_NAME, + "RemoveAuthenticatorViaChallengeContinue", + 1, + req, + ) + .with_access_token(access_token); + let resp = self + .transport + .send_request::( + req, + )?; + Ok(resp) + } + pub fn query_status( &self, req: CTwoFactor_Status_Request, @@ -108,5 +147,13 @@ macro_rules! impl_buildable_req { impl_buildable_req!(CTwoFactor_AddAuthenticator_Request, true); impl_buildable_req!(CTwoFactor_FinalizeAddAuthenticator_Request, true); impl_buildable_req!(CTwoFactor_RemoveAuthenticator_Request, true); +impl_buildable_req!( + CTwoFactor_RemoveAuthenticatorViaChallengeStart_Request, + true +); +impl_buildable_req!( + CTwoFactor_RemoveAuthenticatorViaChallengeContinue_Request, + true +); impl_buildable_req!(CTwoFactor_Status_Request, true); impl_buildable_req!(CTwoFactor_Time_Request, false);