diff --git a/Cargo.lock b/Cargo.lock index a9e34bf..2ff717f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,15 +29,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - [[package]] name = "anyhow" version = "1.0.57" @@ -192,17 +183,50 @@ dependencies = [ [[package]] name = "clap" -version = "2.34.0" +version = "3.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "d53da17d37dba964b9b3ecb5c5a1f193a2762c700e6829201e645b9381c99dc7" dependencies = [ - "ansi_term", "atty", "bitflags", + "clap_derive", + "clap_lex", + "indexmap", + "once_cell", "strsim", + "termcolor", "textwrap", - "unicode-width", - "vec_map", +] + +[[package]] +name = "clap_complete" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6ebaab5f25e4f0312dfa07cb30a755204b96e6531457c2cfdecfdf5f2adf40" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "3.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11d40217d16aee8508cc8e5fde8b4ff24639758608e5374e731b53f85749fb9" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5538cd660450ebeb4234cfecf8f2284b844ffc4c50531e66d584ad5b91293613" +dependencies = [ + "os_str_bytes", ] [[package]] @@ -578,6 +602,12 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -938,6 +968,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "os_str_bytes" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa" + [[package]] name = "parking_lot" version = "0.12.1" @@ -1097,6 +1133,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-hack" version = "0.5.19" @@ -1808,6 +1868,7 @@ dependencies = [ "base64", "block-modes", "clap", + "clap_complete", "cookie 0.14.4", "dirs", "hmac-sha1", @@ -1860,9 +1921,9 @@ dependencies = [ [[package]] name = "strsim" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" @@ -1957,12 +2018,9 @@ checksum = "442f2674e6bd8489052b958e0eaebd89c26eefa3be9dc359d1e2ecccdc510f45" [[package]] name = "textwrap" -version = "0.11.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" [[package]] name = "thin-slice" @@ -2232,12 +2290,6 @@ dependencies = [ "getrandom 0.2.6", ] -[[package]] -name = "vec_map" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" - [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index cdd29e2..caf1ed4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ [package] name = "steamguard-cli" version = "0.4.5" -authors = ["Carson McManus "] +authors = ["dyc3 (Carson McManus) "] edition = "2018" description = "A command line utility to generate Steam 2FA codes and respond to confirmations." keywords = ["steam", "2fa", "steamguard", "authentication", "cli"] @@ -33,7 +33,8 @@ serde_json = "1.0" rsa = "0.5.0" rand = "0.8.4" standback = "0.2.17" # required to fix a compilation error on a transient dependency -clap = "2.33.3" +clap = { version = "3.1.18", features = ["derive", "cargo"] } +clap_complete = "3.2.1" log = "0.4.14" stderrlog = "0.4" cookie = "0.14" diff --git a/src/accountmanager.rs b/src/accountmanager.rs index 2f82556..73dfb60 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -180,8 +180,8 @@ impl Manifest { .insert(account.account_name.clone(), Arc::new(Mutex::new(account))); } - pub fn import_account(&mut self, import_path: String) -> anyhow::Result<()> { - let path = Path::new(&import_path); + pub fn import_account(&mut self, import_path: &String) -> anyhow::Result<()> { + let path = Path::new(import_path); ensure!(path.exists(), "{} does not exist.", import_path); ensure!(path.is_file(), "{} is not a file.", import_path); @@ -576,7 +576,7 @@ mod tests { let mut loaded_manifest = Manifest::new(manifest_path.as_path()); assert!(matches!( loaded_manifest.import_account( - tmp_dir + &tmp_dir .path() .join("asdf1234.maFile") .into_os_string() diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..687d904 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,145 @@ +use clap::{clap_derive::ArgEnum, Parser}; +use clap_complete::Shell; +use std::str::FromStr; + +#[derive(Debug, Clone, Parser)] +#[clap(name="steamguard-cli", bin_name="steamguard", author, version, about = "Generate Steam 2FA codes and confirm Steam trades from the command line.", long_about = None)] +pub(crate) struct Args { + #[clap( + short, + long, + conflicts_with = "all", + help = "Steam username, case-sensitive.", + long_help = "Select the account you want by steam username. Case-sensitive. By default, the first account in the manifest is selected." + )] + pub username: Option, + #[clap( + short, + long, + conflicts_with = "username", + help = "Select all accounts in the manifest." + )] + pub all: bool, + /// The path to the maFiles directory. + #[clap( + short, + long, + help = "Specify which folder your maFiles are in. This should be a path to a folder that contains manifest.json. Default: ~/.config/steamguard-cli/maFiles" + )] + pub mafiles_path: Option, + #[clap(short, long, help = "Specify your encryption passkey.")] + pub passkey: Option, + #[clap(short, long, arg_enum, default_value_t=Verbosity::Info, help = "Set the log level. Be warned, trace is capable of printing sensitive data.")] + pub verbosity: Verbosity, + + #[clap(subcommand)] + pub sub: Option, +} + +#[derive(Debug, Clone, Parser)] +pub(crate) enum Subcommands { + Debug(ArgsDebug), + Completion(ArgsCompletions), + Setup(ArgsSetup), + Import(ArgsImport), + Trade(ArgsTrade), + Remove(ArgsRemove), + Encrypt(ArgsEncrypt), + Decrypt(ArgsDecrypt), +} + +#[derive(Debug, Clone, Copy, ArgEnum)] +pub(crate) enum Verbosity { + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, + Trace = 4, +} + +impl std::fmt::Display for Verbosity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}", + match self { + Verbosity::Error => "error", + Verbosity::Warn => "warn", + Verbosity::Info => "info", + Verbosity::Debug => "debug", + Verbosity::Trace => "trace", + } + )) + } +} + +impl FromStr for Verbosity { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "error" => Ok(Verbosity::Error), + "warn" => Ok(Verbosity::Warn), + "info" => Ok(Verbosity::Info), + "debug" => Ok(Verbosity::Debug), + "trace" => Ok(Verbosity::Trace), + _ => Err(anyhow!("Invalid verbosity level: {}", s)), + } + } +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Debug stuff, not useful for most users.")] +pub(crate) struct ArgsDebug { + #[clap(long, help = "Show an example confirmation menu using dummy data.")] + pub demo_conf_menu: bool, +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Generate shell completions")] +pub(crate) struct ArgsCompletions { + #[clap(short, long, arg_enum, help = "The shell to generate completions for.")] + pub shell: Shell, +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Set up a new account with steamguard-cli")] +pub(crate) struct ArgsSetup { + #[clap(short, long, from_global, help = "Steam username, case-sensitive.")] + pub username: Option, +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Import an account with steamguard already set up")] +pub(crate) struct ArgsImport { + #[clap(long, help = "Paths to one or more maFiles, eg. \"./gaben.maFile\"")] + pub files: Vec, +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Interactive interface for trade confirmations")] +pub(crate) struct ArgsTrade { + #[clap( + short, + long, + help = "Accept all open trade confirmations. Does not open interactive interface." + )] + pub accept_all: bool, + #[clap( + short, + long, + help = "If submitting a confirmation response fails, exit immediately." + )] + pub fail_fast: bool, +} + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Remove the authenticator from an account.")] +pub(crate) struct ArgsRemove; + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Encrypt all maFiles")] +pub(crate) struct ArgsEncrypt; + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Decrypt all maFiles")] +pub(crate) struct ArgsDecrypt; diff --git a/src/errors.rs b/src/errors.rs index 0def6f0..b61bdb0 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -4,4 +4,6 @@ use thiserror::Error; pub(crate) enum UserError { #[error("User aborted the operation.")] Aborted, + #[error("Unknown subcommand. It may need to be implemented.")] + UnknownSubcommand, } diff --git a/src/main.rs b/src/main.rs index 92fe153..d65f3d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ extern crate rpassword; -use clap::{crate_version, App, Arg, ArgMatches, Shell}; +use clap::{IntoApp, Parser}; use log::*; -use std::str::FromStr; use std::{ io::{stdout, Write}, path::Path, @@ -24,115 +23,12 @@ extern crate dirs; extern crate proptest; extern crate ring; mod accountmanager; +mod cli; mod demos; mod encryption; mod errors; mod tui; -fn cli() -> App<'static, 'static> { - App::new("steamguard-cli") - .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") - .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") - ) - .arg( - Arg::with_name("all") - .long("all") - .short("a") - .takes_value(false) - .help("Select all accounts in the manifest.") - .conflicts_with("username") - ) - .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.") - ) - .arg( - Arg::with_name("passkey") - .long("passkey") - .short("p") - .help("Specify your encryption passkey.") - .takes_value(true) - ) - .arg( - Arg::with_name("verbosity") - .short("v") - .help("Log what is going on verbosely.") - .takes_value(false) - .multiple(true) - ) - .subcommand( - App::new("completion") - .about("Generate shell completions") - .arg( - Arg::with_name("shell") - .long("shell") - .takes_value(true) - .possible_values(&Shell::variants()) - ) - ) - .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.") - ) - ) - .subcommand( - App::new("setup") - .about("Set up a new account with steamguard-cli") - ) - .subcommand( - App::new("import") - .about("Import an account with steamguard already set up") - .arg( - Arg::with_name("files") - .required(true) - .multiple(true) - ) - ) - .subcommand( - App::new("remove") - .about("Remove the authenticator from an account.") - ) - .subcommand( - App::new("encrypt") - .about("Encrypt maFiles.") - ) - .subcommand( - App::new("decrypt") - .about("Decrypt maFiles.") - ) - .subcommand( - App::new("debug") - .arg( - Arg::with_name("demo-conf-menu") - .help("Show an example confirmation menu using dummy data.") - .takes_value(false) - ) - ) -} - fn main() { std::process::exit(match run() { Ok(_) => 0, @@ -144,33 +40,28 @@ fn main() { } fn run() -> anyhow::Result<()> { - let matches = cli().get_matches(); + let args = cli::Args::parse(); + info!("{:?}", args); - let verbosity = matches.occurrences_of("verbosity") as usize + 2; stderrlog::new() - .verbosity(verbosity) + .verbosity(args.verbosity as usize) .module(module_path!()) .module("steamguard") .init() .unwrap(); - if let Some(demo_matches) = matches.subcommand_matches("debug") { - if demo_matches.is_present("demo-conf-menu") { - demos::demo_confirmation_menu(); + match args.sub { + Some(cli::Subcommands::Debug(args)) => { + return do_subcmd_debug(args); } - return Ok(()); - } - 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(()); - } + Some(cli::Subcommands::Completion(args)) => { + return do_subcmd_completion(args); + } + _ => {} + }; - let mafiles_dir = if matches.occurrences_of("mafiles-path") > 0 { - matches.value_of("mafiles-path").unwrap().into() + let mafiles_dir = if let Some(mafiles_path) = &args.mafiles_path { + mafiles_path.clone() } else { get_mafiles_dir() }; @@ -197,7 +88,7 @@ fn run() -> anyhow::Result<()> { manifest = accountmanager::Manifest::load(path.as_path())?; } - let mut passkey: Option = matches.value_of("passkey").map(|s| s.into()); + let mut passkey: Option = args.passkey.clone(); manifest.submit_passkey(passkey); loop { @@ -228,159 +119,25 @@ fn run() -> anyhow::Result<()> { } } - 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!( - "Account {} already exists in manifest, remove it first", - username - ); + match args.sub { + Some(cli::Subcommands::Setup(args)) => { + return do_subcmd_setup(args, &mut manifest); } - 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; - } - 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."); - } - Err(AccountLinkError::MustProvidePhoneNumber) => { - println!("Enter your phone number in the following format: +1 123-456-7890"); - print!("Phone number: "); - linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], ""); - } - Err(AccountLinkError::AuthenticatorPresent) => { - println!("An authenticator is already present on this account."); - bail!("An authenticator is already present on this account."); - } - Err(AccountLinkError::MustConfirmEmail) => { - println!("Check your email and click the link."); - tui::pause(); - } - Err(err) => { - error!( - "Failed to link authenticator. Account has not been linked. {}", - err - ); - return Err(err.into()); - } - } + Some(cli::Subcommands::Import(args)) => { + return do_subcmd_import(args, &mut manifest); } - 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()); - } + Some(cli::Subcommands::Encrypt(args)) => { + return do_subcmd_encrypt(args, &mut manifest); } - - let account_arc = manifest.get_account(&account_name).unwrap(); - let mut account = account_arc.lock().unwrap(); - - 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(); - let mut tries = 0; - loop { - match linker.finalize(&mut account, sms_code.clone()) { - 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"); - } - } - Err(err) => { - error!("Failed to finalize: {}", err); - return Err(err.into()); - } - } + Some(cli::Subcommands::Decrypt(args)) => { + return do_subcmd_decrypt(args, &mut manifest); } - - println!("Authenticator finalized."); - match manifest.save() { - Ok(_) => {} - Err(err) => { - println!( - "Failed to save manifest, but we were able to save it before. {}", - err - ); - return Err(err); - } - } - - println!( - "Authenticator has been finalized. Please actually write down your revocation code: {}", - account.revocation_code - ); - - return Ok(()); - } 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); - } - } - } - - manifest.save()?; - return Ok(()); - } 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()?; - for entry in &mut manifest.entries { - entry.encryption = Some(accountmanager::EntryEncryptionParams::generate()); - } - manifest.save()?; - return Ok(()); - } else if matches.is_present("decrypt") { - manifest.load_accounts()?; - for entry in &mut manifest.entries { - entry.encryption = None; - } - manifest.submit_passkey(None); - manifest.save()?; - return Ok(()); + _ => {} } - let mut selected_accounts: Vec>>; + let selected_accounts: Vec>>; loop { - match get_selected_accounts(&matches, &mut manifest) { + match get_selected_accounts(&args, &mut manifest) { Ok(accounts) => { selected_accounts = accounts; break; @@ -410,171 +167,38 @@ fn run() -> anyhow::Result<()> { .collect::>() ); - if let Some(trade_matches) = matches.subcommand_matches("trade") { - info!("trade"); - for a in selected_accounts.iter_mut() { - let mut account = a.lock().unwrap(); - - info!("Checking for trade confirmations"); - let confirmations: Vec; - 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)?; - } - } - } - - let mut any_failed = false; - let fail_fast = trade_matches.is_present("fail-fast"); - 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); - } - } - } else { - if termion::is_tty(&stdout()) { - let (accept, deny) = tui::prompt_confirmation_menu(confirmations); - 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); - } - } - 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); - } - } - } 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."); - } + match args.sub { + Some(cli::Subcommands::Trade(args)) => { + return do_subcmd_trade(args, &mut manifest, selected_accounts); } - - manifest.save()?; - } 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::>() - .join(", ") - ); - - match tui::prompt_char("Do you want to continue?", "yN") { - 'y' => {} - _ => { - info!("Aborting!"); - return Err(errors::UserError::Aborted.into()); - } + Some(cli::Subcommands::Remove(args)) => { + return do_subcmd_remove(args, &mut manifest, selected_accounts); } - - 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 - ); - match tui::prompt_char( - "Would you like to remove it from the manifest anyway?", - "yN", - ) { - 'y' => { - successful.push(account.account_name.clone()); - } - _ => {} - } - } - } - Err(err) => { - error!( - "Unexpected error when removing authenticator from {}: {}", - account.account_name, err - ); - } - } + Some(s) => { + error!("Unknown subcommand: {:?}", s); + return Err(errors::UserError::UnknownSubcommand.into()); } - - for account_name in successful { - manifest.remove_account(account_name); - } - - manifest.save()?; - } else { - let server_time = steamapi::get_server_time(); - debug!("Time used to generate codes: {}", server_time); - for account in selected_accounts { - info!( - "Generating code for {}", - account.lock().unwrap().account_name - ); - trace!("{:?}", account); - let code = account.lock().unwrap().generate_code(server_time); - println!("{}", code); + _ => { + debug!("No subcommand given, assuming user wants a 2fa code"); + return do_subcmd_code(selected_accounts); } } - Ok(()) } fn get_selected_accounts( - matches: &ArgMatches, + args: &cli::Args, manifest: &mut accountmanager::Manifest, ) -> anyhow::Result>>, ManifestAccountLoadError> { let mut selected_accounts: Vec>> = vec![]; - if matches.is_present("all") { + if args.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()) + let entry = if let Some(username) = &args.username { + manifest.get_entry(&username) } else { manifest .entries @@ -681,3 +305,352 @@ fn get_mafiles_dir() -> String { return paths[0].to_str().unwrap().into(); } + +fn do_subcmd_debug(args: cli::ArgsDebug) -> anyhow::Result<()> { + if args.demo_conf_menu { + demos::demo_confirmation_menu(); + } + return Ok(()); +} + +fn do_subcmd_completion(args: cli::ArgsCompletions) -> Result<(), anyhow::Error> { + let mut app = cli::Args::command_for_update(); + clap_complete::generate(args.shell, &mut app, "steamguard", &mut std::io::stdout()); + return Ok(()); +} + +fn do_subcmd_setup( + args: cli::ArgsSetup, + manifest: &mut accountmanager::Manifest, +) -> anyhow::Result<()> { + println!("Log in to the account that you want to link to steamguard-cli"); + print!("Username: "); + let username = if args.username.is_some() { + let u = args.username.unwrap(); + println!("{}", u); + u + } else { + tui::prompt() + }; + let account_name = username.clone(); + if manifest.account_exists(&username) { + bail!( + "Account {} already exists in manifest, remove it first", + username + ); + } + 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; + } + 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."); + } + Err(AccountLinkError::MustProvidePhoneNumber) => { + println!("Enter your phone number in the following format: +1 123-456-7890"); + print!("Phone number: "); + linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], ""); + } + Err(AccountLinkError::AuthenticatorPresent) => { + println!("An authenticator is already present on this account."); + bail!("An authenticator is already present on this account."); + } + Err(AccountLinkError::MustConfirmEmail) => { + println!("Check your email and click the link."); + tui::pause(); + } + 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(); + + 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(); + let mut tries = 0; + loop { + match linker.finalize(&mut account, sms_code.clone()) { + 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"); + } + } + Err(err) => { + error!("Failed to finalize: {}", err); + return Err(err.into()); + } + } + } + + println!("Authenticator finalized."); + match manifest.save() { + Ok(_) => {} + Err(err) => { + println!( + "Failed to save manifest, but we were able to save it before. {}", + err + ); + return Err(err); + } + } + + println!( + "Authenticator has been finalized. Please actually write down your revocation code: {}", + account.revocation_code + ); + + return Ok(()); +} + +fn do_subcmd_import( + args: cli::ArgsImport, + manifest: &mut accountmanager::Manifest, +) -> anyhow::Result<()> { + for file_path in args.files { + match manifest.import_account(&file_path) { + Ok(_) => { + info!("Imported account: {}", &file_path); + } + Err(err) => { + bail!("Failed to import account: {} {}", &file_path, err); + } + } + } + + manifest.save()?; + return Ok(()); +} + +fn do_subcmd_trade( + args: cli::ArgsTrade, + manifest: &mut accountmanager::Manifest, + mut selected_accounts: Vec>>, +) -> anyhow::Result<()> { + for a in selected_accounts.iter_mut() { + let mut account = a.lock().unwrap(); + + info!("Checking for trade confirmations"); + let confirmations: Vec; + 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)?; + } + } + } + + let mut any_failed = false; + if args.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 args.fail_fast { + return result; + } + } else { + debug!("accept confirmation result: {:?}", result); + } + } + } else { + if termion::is_tty(&stdout()) { + let (accept, deny) = tui::prompt_confirmation_menu(confirmations); + for conf in &accept { + let result = account.accept_confirmation(conf); + if result.is_err() { + warn!("accept confirmation result: {:?}", result); + any_failed = true; + if args.fail_fast { + return result; + } + } else { + debug!("accept confirmation result: {:?}", result); + } + } + 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 args.fail_fast { + return result; + } + } else { + debug!("deny confirmation result: {:?}", result); + } + } + } 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."); + } + } + + manifest.save()?; + return Ok(()); +} + +fn do_subcmd_remove( + _args: cli::ArgsRemove, + manifest: &mut accountmanager::Manifest, + selected_accounts: Vec>>, +) -> anyhow::Result<()> { + println!( + "This will remove the mobile authenticator from {} accounts: {}", + selected_accounts.len(), + selected_accounts + .iter() + .map(|a| a.lock().unwrap().account_name.clone()) + .collect::>() + .join(", ") + ); + + match tui::prompt_char("Do you want to continue?", "yN") { + 'y' => {} + _ => { + info!("Aborting!"); + return Err(errors::UserError::Aborted.into()); + } + } + + 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 + ); + match tui::prompt_char( + "Would you like to remove it from the manifest anyway?", + "yN", + ) { + 'y' => { + successful.push(account.account_name.clone()); + } + _ => {} + } + } + } + Err(err) => { + error!( + "Unexpected error when removing authenticator from {}: {}", + account.account_name, err + ); + } + } + } + + for account_name in successful { + manifest.remove_account(account_name); + } + + manifest.save()?; + return Ok(()); +} + +fn do_subcmd_encrypt( + _args: cli::ArgsEncrypt, + manifest: &mut accountmanager::Manifest, +) -> anyhow::Result<()> { + if !manifest.has_passkey() { + let mut 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()?; + for entry in &mut manifest.entries { + entry.encryption = Some(accountmanager::EntryEncryptionParams::generate()); + } + manifest.save()?; + return Ok(()); +} + +fn do_subcmd_decrypt( + _args: cli::ArgsDecrypt, + manifest: &mut accountmanager::Manifest, +) -> anyhow::Result<()> { + manifest.load_accounts()?; + for entry in &mut manifest.entries { + entry.encryption = None; + } + manifest.submit_passkey(None); + manifest.save()?; + return Ok(()); +} + +fn do_subcmd_code(selected_accounts: Vec>>) -> anyhow::Result<()> { + let server_time = steamapi::get_server_time(); + debug!("Time used to generate codes: {}", server_time); + for account in selected_accounts { + info!( + "Generating code for {}", + account.lock().unwrap().account_name + ); + trace!("{:?}", account); + let code = account.lock().unwrap().generate_code(server_time); + println!("{}", code); + } + return Ok(()); +}