steamguard-cli/src/main.rs

654 lines
17 KiB
Rust
Raw Normal View History

extern crate rpassword;
use clap::{crate_version, App, Arg, Shell, ArgMatches};
2021-03-27 15:35:52 +01:00
use log::*;
2021-08-18 00:12:49 +02:00
use std::str::FromStr;
2021-08-01 14:43:18 +02:00
use std::{
io::{stdout, Write},
2021-08-08 18:54:46 +02:00
path::Path,
sync::{Arc, Mutex},
2021-08-01 14:43:18 +02:00
};
2021-08-08 18:34:06 +02:00
use steamguard::{
steamapi, AccountLinkError, AccountLinker, Confirmation, FinalizeLinkError, LoginError,
SteamGuardAccount, UserLogin,
2021-08-01 14:43:18 +02:00
};
use crate::accountmanager::ManifestAccountLoadError;
#[macro_use]
extern crate lazy_static;
2021-08-01 17:20:57 +02:00
#[macro_use]
extern crate anyhow;
2021-08-15 17:52:54 +02:00
extern crate base64;
extern crate dirs;
2021-08-20 15:37:55 +02:00
#[cfg(test)]
extern crate proptest;
2021-08-15 17:52:54 +02:00
extern crate ring;
2021-08-01 14:43:18 +02:00
mod accountmanager;
mod demos;
2021-08-19 22:54:18 +02:00
mod encryption;
mod errors;
mod tui;
2021-08-18 00:12:49 +02:00
fn cli() -> App<'static, 'static> {
App::new("steamguard-cli")
2021-03-27 14:31:38 +01:00
.version(crate_version!())
.bin_name("steamguard")
.author("dyc3 (Carson McManus)")
.about("Generate Steam 2FA codes and confirm Steam trades from the command line.")
.arg(
Arg::with_name("username")
.long("username")
.short("u")
2021-08-10 04:25:39 +02:00
.takes_value(true)
.help("Select the account you want by steam username. Case-sensitive. By default, the first account in the manifest is selected.")
.conflicts_with("all")
2021-03-27 14:31:38 +01:00
)
.arg(
Arg::with_name("all")
.long("all")
.short("a")
2021-03-27 17:14:34 +01:00
.takes_value(false)
2021-03-27 14:31:38 +01:00
.help("Select all accounts in the manifest.")
.conflicts_with("username")
2021-03-27 14:31:38 +01:00
)
.arg(
Arg::with_name("mafiles-path")
.long("mafiles-path")
.short("m")
.default_value("~/maFiles")
.help("Specify which folder your maFiles are in. This should be a path to a folder that contains manifest.json.")
2021-03-27 14:31:38 +01:00
)
.arg(
Arg::with_name("passkey")
.long("passkey")
.short("p")
.help("Specify your encryption passkey.")
2021-08-18 01:04:02 +02:00
.takes_value(true)
2021-03-27 14:31:38 +01:00
)
2021-03-27 15:35:52 +01:00
.arg(
Arg::with_name("verbosity")
.short("v")
.help("Log what is going on verbosely.")
.takes_value(false)
.multiple(true)
)
2021-08-18 00:12:49 +02:00
.subcommand(
App::new("completion")
.about("Generate shell completions")
.arg(
Arg::with_name("shell")
.long("shell")
.takes_value(true)
.possible_values(&Shell::variants())
)
)
2021-03-27 14:31:38 +01:00
.subcommand(
App::new("trade")
.about("Interactive interface for trade confirmations")
.arg(
Arg::with_name("accept-all")
.short("a")
.long("accept-all")
.takes_value(false)
.help("Accept all open trade confirmations. Does not open interactive interface.")
)
.arg(
Arg::with_name("fail-fast")
.takes_value(false)
.help("If submitting a confirmation response fails, exit immediately.")
2021-03-27 14:31:38 +01:00
)
)
2021-07-27 22:24:56 +02:00
.subcommand(
App::new("setup")
.about("Set up a new account with steamguard-cli")
)
2021-08-14 01:04:03 +02:00
.subcommand(
App::new("import")
.about("Import an account with steamguard already set up")
.arg(
Arg::with_name("files")
.required(true)
.multiple(true)
)
)
2021-08-12 01:39:29 +02:00
.subcommand(
App::new("remove")
.about("Remove the authenticator from an account.")
)
2021-08-16 05:20:49 +02:00
.subcommand(
App::new("encrypt")
.about("Encrypt maFiles.")
)
.subcommand(
App::new("decrypt")
.about("Decrypt maFiles.")
)
2021-07-30 01:42:45 +02:00
.subcommand(
App::new("debug")
.arg(
Arg::with_name("demo-conf-menu")
.help("Show an example confirmation menu using dummy data.")
.takes_value(false)
)
)
2021-08-18 00:12:49 +02:00
}
fn main() {
std::process::exit(match run() {
Ok(_) => 0,
Err(e) => {
error!("{:?}", e);
255
}
});
}
fn run() -> anyhow::Result<()> {
2021-08-18 00:12:49 +02:00
let matches = cli().get_matches();
2021-03-27 14:31:38 +01:00
2021-08-08 18:54:46 +02:00
let verbosity = matches.occurrences_of("verbosity") as usize + 2;
stderrlog::new()
.verbosity(verbosity)
.module(module_path!())
2021-08-10 01:48:18 +02:00
.module("steamguard")
2021-08-08 18:54:46 +02:00
.init()
.unwrap();
2021-03-22 02:21:29 +01:00
2021-08-08 18:54:46 +02:00
if let Some(demo_matches) = matches.subcommand_matches("debug") {
if demo_matches.is_present("demo-conf-menu") {
demos::demo_confirmation_menu();
2021-08-08 18:54:46 +02:00
}
return Ok(());
2021-08-08 18:54:46 +02:00
}
2021-08-18 00:12:49 +02:00
if let Some(completion_matches) = matches.subcommand_matches("completion") {
cli().gen_completions_to(
"steamguard",
Shell::from_str(completion_matches.value_of("shell").unwrap()).unwrap(),
&mut std::io::stdout(),
);
return Ok(());
2021-08-18 00:12:49 +02:00
}
2021-07-30 01:42:45 +02:00
let mafiles_dir = if matches.occurrences_of("mafiles-path") > 0 {
matches.value_of("mafiles-path").unwrap().into()
} else {
get_mafiles_dir()
};
info!("reading manifest from {}", mafiles_dir);
let path = Path::new(&mafiles_dir).join("manifest.json");
2021-08-08 18:54:46 +02:00
let mut manifest: accountmanager::Manifest;
if !path.exists() {
error!("Did not find manifest in {}", mafiles_dir);
2021-08-14 17:10:21 +02:00
match tui::prompt_char(
format!("Would you like to create a manifest in {} ?", mafiles_dir).as_str(),
"Yn",
) {
'n' => {
info!("Aborting!");
return Err(errors::UserError::Aborted.into());
}
_ => {}
2021-08-08 18:54:46 +02:00
}
std::fs::create_dir_all(mafiles_dir)?;
manifest = accountmanager::Manifest::new(path.as_path());
manifest.save()?;
2021-08-13 00:54:38 +02:00
} else {
manifest = accountmanager::Manifest::load(path.as_path())?;
2021-08-08 18:54:46 +02:00
}
2021-03-27 15:35:52 +01:00
2021-08-17 03:13:58 +02:00
let mut passkey: Option<String> = matches.value_of("passkey").map(|s| s.into());
manifest.submit_passkey(passkey);
2021-08-16 05:20:49 +02:00
2021-08-17 03:13:58 +02:00
loop {
2022-02-22 15:19:56 +01:00
match manifest.auto_upgrade() {
Ok(upgraded) => {
if upgraded {
info!("Manifest auto-upgraded");
manifest.save()?;
}
break;
},
2021-08-17 03:13:58 +02:00
Err(
accountmanager::ManifestAccountLoadError::MissingPasskey
2021-08-20 16:01:23 +02:00
| accountmanager::ManifestAccountLoadError::IncorrectPasskey,
2021-08-17 03:13:58 +02:00
) => {
if manifest.has_passkey() {
error!("Incorrect passkey");
}
2021-08-17 03:13:58 +02:00
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
manifest.submit_passkey(passkey);
2021-08-17 03:13:58 +02:00
}
Err(e) => {
error!("Could not load accounts: {}", e);
return Err(e.into());
2021-08-17 03:13:58 +02:00
}
}
}
2021-07-27 22:24:56 +02:00
2021-08-08 18:54:46 +02:00
if matches.is_present("setup") {
println!("Log in to the account that you want to link to steamguard-cli");
print!("Username: ");
let username = tui::prompt();
let account_name = username.clone();
if manifest.account_exists(&username) {
bail!(
2021-09-06 22:57:36 +02:00
"Account {} already exists in manifest, remove it first",
username
);
}
2021-09-06 22:57:36 +02:00
let session =
do_login_raw(username).expect("Failed to log in. Account has not been linked.");
let mut linker = AccountLinker::new(session);
let account: SteamGuardAccount;
loop {
match linker.link() {
Ok(a) => {
account = a;
break;
}
2021-08-10 01:09:48 +02:00
Err(AccountLinkError::MustRemovePhoneNumber) => {
println!("There is already a phone number on this account, please remove it and try again.");
bail!("There is already a phone number on this account, please remove it and try again.");
2021-08-10 01:09:48 +02:00
}
Err(AccountLinkError::MustProvidePhoneNumber) => {
2021-08-10 03:41:20 +02:00
println!("Enter your phone number in the following format: +1 123-456-7890");
print!("Phone number: ");
linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], "");
2021-08-10 01:09:48 +02:00
}
Err(AccountLinkError::AuthenticatorPresent) => {
println!("An authenticator is already present on this account.");
bail!("An authenticator is already present on this account.");
2021-08-10 01:09:48 +02:00
}
Err(AccountLinkError::MustConfirmEmail) => {
println!("Check your email and click the link.");
tui::pause();
2021-08-10 01:09:48 +02:00
}
Err(err) => {
error!(
"Failed to link authenticator. Account has not been linked. {}",
err
);
return Err(err.into());
}
}
}
manifest.add_account(account);
match manifest.save() {
Ok(_) => {}
Err(err) => {
error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", err);
println!(
"Just in case, here is the account info. Save it somewhere just in case!\n{:?}",
manifest.get_account(&account_name).unwrap().lock().unwrap()
);
return Err(err.into());
}
}
let account_arc = manifest.get_account(&account_name).unwrap();
let mut account = account_arc.lock().unwrap();
2021-08-09 06:09:34 +02:00
println!("Authenticator has not yet been linked. Before continuing with finalization, please take the time to write down your revocation code: {}", account.revocation_code);
tui::pause();
debug!("attempting link finalization");
print!("Enter SMS code: ");
let sms_code = tui::prompt();
2021-08-09 06:09:34 +02:00
let mut tries = 0;
loop {
match linker.finalize(&mut account, sms_code.clone()) {
2021-08-09 06:09:34 +02:00
Ok(_) => break,
Err(FinalizeLinkError::WantMore) => {
debug!("steam wants more 2fa codes (tries: {})", tries);
tries += 1;
if tries >= 30 {
error!("Failed to finalize: unable to generate valid 2fa codes");
bail!("Failed to finalize: unable to generate valid 2fa codes");
2021-08-09 06:09:34 +02:00
}
}
2021-08-09 06:09:34 +02:00
Err(err) => {
error!("Failed to finalize: {}", err);
return Err(err.into());
2021-08-09 06:09:34 +02:00
}
}
}
2021-08-11 03:10:04 +02:00
println!("Authenticator finalized.");
match manifest.save() {
2021-08-11 03:10:04 +02:00
Ok(_) => {}
Err(err) => {
2021-08-11 04:45:17 +02:00
println!(
"Failed to save manifest, but we were able to save it before. {}",
err
);
return Err(err);
2021-08-11 03:10:04 +02:00
}
}
2021-08-25 15:09:02 +02:00
println!(
"Authenticator has been finalized. Please actually write down your revocation code: {}",
account.revocation_code
);
return Ok(());
2021-08-14 01:04:03 +02:00
} else if let Some(import_matches) = matches.subcommand_matches("import") {
for file_path in import_matches.values_of("files").unwrap() {
match manifest.import_account(file_path.into()) {
Ok(_) => {
info!("Imported account: {}", file_path);
}
Err(err) => {
bail!("Failed to import account: {} {}", file_path, err);
2021-08-14 01:04:03 +02:00
}
}
}
manifest.save()?;
return Ok(());
2021-08-16 05:20:49 +02:00
} else if matches.is_present("encrypt") {
if !manifest.has_passkey() {
loop {
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
let passkey_confirm =
rpassword::prompt_password_stdout("Confirm encryption passkey: ").ok();
if passkey == passkey_confirm {
break;
}
error!("Passkeys do not match, try again.");
}
manifest.submit_passkey(passkey);
}
manifest.load_accounts()?;
2021-08-16 05:20:49 +02:00
for entry in &mut manifest.entries {
entry.encryption = Some(accountmanager::EntryEncryptionParams::generate());
}
manifest.save()?;
return Ok(());
2021-08-18 00:54:16 +02:00
} else if matches.is_present("decrypt") {
manifest.load_accounts()?;
2021-08-18 00:54:16 +02:00
for entry in &mut manifest.entries {
entry.encryption = None;
}
manifest.submit_passkey(None);
manifest.save()?;
return Ok(());
2021-08-08 18:54:46 +02:00
}
2021-07-27 22:24:56 +02:00
let mut selected_accounts = get_selected_accounts(&matches, &mut manifest)?;
2021-03-27 17:14:34 +01:00
2021-08-08 18:54:46 +02:00
debug!(
"selected accounts: {:?}",
selected_accounts
.iter()
.map(|a| a.lock().unwrap().account_name.clone())
.collect::<Vec<String>>()
);
2021-03-27 17:14:34 +01:00
2021-08-08 18:54:46 +02:00
if let Some(trade_matches) = matches.subcommand_matches("trade") {
info!("trade");
for a in selected_accounts.iter_mut() {
let mut account = a.lock().unwrap();
2021-04-04 20:07:06 +02:00
2021-08-08 18:54:46 +02:00
info!("Checking for trade confirmations");
let confirmations: Vec<Confirmation>;
loop {
match account.get_trade_confirmations() {
Ok(confs) => {
confirmations = confs;
break;
}
Err(_) => {
info!("failed to get trade confirmations, asking user to log in");
do_login(&mut account)?;
2021-08-08 18:54:46 +02:00
}
}
}
2021-07-29 15:08:06 +02:00
let mut any_failed = false;
let fail_fast = trade_matches.is_present("fail-fast");
2021-08-08 18:54:46 +02:00
if trade_matches.is_present("accept-all") {
info!("accepting all confirmations");
for conf in &confirmations {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
2021-08-08 18:54:46 +02:00
}
} else {
if termion::is_tty(&stdout()) {
let (accept, deny) = tui::prompt_confirmation_menu(confirmations);
2021-08-08 18:54:46 +02:00
for conf in &accept {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
2021-08-08 18:54:46 +02:00
}
for conf in &deny {
let result = account.deny_confirmation(conf);
debug!("deny confirmation result: {:?}", result);
if result.is_err() {
warn!("deny confirmation result: {:?}", result);
any_failed = true;
if fail_fast {
return result;
}
} else {
debug!("deny confirmation result: {:?}", result);
}
2021-08-08 18:54:46 +02:00
}
} else {
warn!("not a tty, not showing menu");
for conf in &confirmations {
println!("{}", conf.description());
}
}
}
if any_failed {
error!("Failed to respond to some confirmations.");
}
2021-08-08 18:54:46 +02:00
}
2021-08-01 17:20:57 +02:00
manifest.save()?;
2021-08-12 01:39:29 +02:00
} else if let Some(_) = matches.subcommand_matches("remove") {
println!(
"This will remove the mobile authenticator from {} accounts: {}",
selected_accounts.len(),
selected_accounts
.iter()
.map(|a| a.lock().unwrap().account_name.clone())
.collect::<Vec<String>>()
.join(", ")
);
2021-08-14 17:10:21 +02:00
match tui::prompt_char("Do you want to continue?", "yN") {
'y' => {}
2021-08-12 01:39:29 +02:00
_ => {
2021-08-14 17:10:21 +02:00
info!("Aborting!");
return Err(errors::UserError::Aborted.into());
2021-08-12 01:39:29 +02:00
}
}
let mut successful = vec![];
for a in selected_accounts {
let account = a.lock().unwrap();
match account.remove_authenticator(None) {
Ok(success) => {
if success {
println!("Removed authenticator from {}", account.account_name);
successful.push(account.account_name.clone());
} else {
println!(
"Failed to remove authenticator from {}",
account.account_name
);
2021-09-06 22:57:36 +02:00
match tui::prompt_char(
"Would you like to remove it from the manifest anyway?",
"yN",
) {
'y' => {
successful.push(account.account_name.clone());
}
_ => {}
}
2021-08-12 01:39:29 +02:00
}
}
Err(err) => {
error!(
2021-08-12 01:39:29 +02:00
"Unexpected error when removing authenticator from {}: {}",
account.account_name, err
);
}
}
}
for account_name in successful {
manifest.remove_account(account_name);
}
manifest.save()?;
2021-08-08 18:54:46 +02:00
} else {
let server_time = steamapi::get_server_time();
2021-09-06 21:41:22 +02:00
debug!("Time used to generate codes: {}", server_time);
2021-08-08 18:54:46 +02:00
for account in selected_accounts {
2021-09-06 22:05:26 +02:00
info!(
"Generating code for {}",
account.lock().unwrap().account_name
);
2021-08-08 18:54:46 +02:00
trace!("{:?}", account);
let code = account.lock().unwrap().generate_code(server_time);
println!("{}", code);
}
}
Ok(())
2021-03-22 02:21:29 +01:00
}
fn get_selected_accounts(matches: &ArgMatches, manifest: &mut accountmanager::Manifest) -> anyhow::Result<Vec<Arc<Mutex<SteamGuardAccount>>>, ManifestAccountLoadError> {
let mut selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>> = vec![];
if matches.is_present("all") {
manifest.load_accounts()?;
for entry in &manifest.entries {
selected_accounts.push(manifest.get_account(&entry.account_name).unwrap().clone());
}
} else {
let entry = if matches.is_present("username") {
manifest.get_entry(&matches.value_of("username").unwrap().into())
} else {
manifest.entries.first().ok_or(ManifestAccountLoadError::MissingManifestEntry)
}?;
let account_name = entry.account_name.clone();
let account = manifest.get_or_load_account(&account_name)?;
selected_accounts.push(account);
}
return Ok(selected_accounts);
}
2021-08-14 17:24:15 +02:00
fn do_login(account: &mut SteamGuardAccount) -> anyhow::Result<()> {
2021-08-08 18:54:46 +02:00
if account.account_name.len() > 0 {
println!("Username: {}", account.account_name);
} else {
print!("Username: ");
account.account_name = tui::prompt();
2021-08-08 18:54:46 +02:00
}
let _ = std::io::stdout().flush();
let password = rpassword::prompt_password_stdout("Password: ").unwrap();
if password.len() > 0 {
debug!("password is present");
} else {
debug!("password is empty");
}
2021-08-14 17:24:15 +02:00
account.session = Some(do_login_impl(
account.account_name.clone(),
password,
Some(account),
)?);
return Ok(());
2021-07-27 22:24:56 +02:00
}
2021-07-30 01:42:45 +02:00
fn do_login_raw(username: String) -> anyhow::Result<steamapi::Session> {
let _ = std::io::stdout().flush();
let password = rpassword::prompt_password_stdout("Password: ").unwrap();
if password.len() > 0 {
debug!("password is present");
} else {
debug!("password is empty");
}
2021-08-14 17:24:15 +02:00
return do_login_impl(username, password, None);
}
fn do_login_impl(
username: String,
password: String,
account: Option<&SteamGuardAccount>,
) -> anyhow::Result<steamapi::Session> {
// TODO: reprompt if password is empty
let mut login = UserLogin::new(username, password);
let mut loops = 0;
loop {
match login.login() {
Ok(s) => {
return Ok(s);
}
2021-08-14 17:24:15 +02:00
Err(LoginError::Need2FA) => match account {
Some(a) => {
let server_time = steamapi::get_server_time();
login.twofactor_code = a.generate_code(server_time);
}
None => {
print!("Enter 2fa code: ");
login.twofactor_code = tui::prompt();
}
},
Err(LoginError::NeedCaptcha { captcha_gid }) => {
debug!("need captcha to log in");
login.captcha_text = tui::prompt_captcha_text(&captcha_gid);
}
Err(LoginError::NeedEmail) => {
println!("You should have received an email with a code.");
print!("Enter code: ");
login.email_code = tui::prompt();
}
Err(r) => {
error!("Fatal login result: {:?}", r);
bail!(r);
}
}
loops += 1;
if loops > 2 {
error!("Too many loops. Aborting login process, to avoid getting rate limited.");
bail!("Too many loops. Login process aborted to avoid getting rate limited.");
}
}
}
fn get_mafiles_dir() -> String {
let paths = vec![
Path::new(&dirs::config_dir().unwrap()).join("steamguard-cli/maFiles"),
Path::new(&dirs::home_dir().unwrap()).join("maFiles"),
];
for path in &paths {
if path.join("manifest.json").is_file() {
return path.to_str().unwrap().into();
}
}
return paths[0].to_str().unwrap().into();
}