Merge pull request #56 from dyc3/rust-rewrite

rust rewrite
This commit is contained in:
Carson McManus 2021-07-30 12:33:49 -04:00 committed by GitHub
commit 98d0bb72c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 3053 additions and 2 deletions

2
.gitignore vendored
View file

@ -6,3 +6,5 @@ SteamAuth/SteamAuth/packages/
test_maFiles/ test_maFiles/
*.deb *.deb
target/

1741
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

28
Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "steamguard-cli"
version = "0.2.0"
authors = ["Carson McManus <carson.mcmanus1@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "^1.0"
hmac-sha1 = "^0.1"
base64 = "0.13.0"
text_io = "0.1.8"
rpassword = "5.0"
reqwest = { version = "0.11", features = ["blocking", "json", "cookies", "gzip"] }
serde = { version = "1.0", features = ["derive"] }
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"
log = "0.4.14"
stderrlog = "0.4"
cookie = "0.14"
regex = "1"
lazy_static = "1.4.0"
uuid = { version = "0.8", features = ["v4"] }
termion = "1.5.6"

View file

@ -2,6 +2,8 @@
A linux utility for setting up and using Steam Mobile Authenticator (AKA Steam 2FA) on the command line. A linux utility for setting up and using Steam Mobile Authenticator (AKA Steam 2FA) on the command line.
**This utility is in beta.** **This utility is in beta.**
**We are in the process of rewriting steamguard-cli from scratch in Rust.** Any help would be greatly appreciated! See #55 for discussion. The instructions in this document refer to the C# version.
# Disclaimer # Disclaimer
**Use this software at your own risk.** **Use this software at your own risk.**

86
src/accountlinker.rs Normal file
View file

@ -0,0 +1,86 @@
use std::collections::HashMap;
use reqwest::{Url, cookie::{CookieStore}, header::COOKIE, header::{SET_COOKIE, USER_AGENT}};
use serde::{Serialize, Deserialize};
use serde_json::Value;
use steamguard_cli::{SteamGuardAccount, steamapi::Session};
use log::*;
#[derive(Debug, Clone)]
pub struct AccountLinker {
device_id: String,
phone_number: String,
pub account: SteamGuardAccount,
client: reqwest::blocking::Client,
}
impl AccountLinker {
pub fn new() -> AccountLinker {
return AccountLinker{
device_id: generate_device_id(),
phone_number: String::from(""),
account: SteamGuardAccount::new(),
client: reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()
.unwrap(),
}
}
pub fn link(&self, session: &mut Session) {
let mut params = HashMap::new();
params.insert("access_token", session.token.clone());
params.insert("steamid", session.steam_id.to_string());
params.insert("device_identifier", self.device_id.clone());
params.insert("authenticator_type", String::from("1"));
params.insert("sms_phone_id", String::from("1"));
}
fn has_phone(&self, session: &Session) -> bool {
return self._phoneajax(session, "has_phone", "null");
}
fn _phoneajax(&self, session: &Session, op: &str, arg: &str) -> bool {
trace!("_phoneajax: op={}", op);
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = reqwest::cookie::Jar::default();
cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url);
cookies.add_cookie_str("mobileClient=android", &url);
cookies.add_cookie_str("Steam_Language=english", &url);
let mut params = HashMap::new();
params.insert("op", op);
params.insert("arg", arg);
params.insert("sessionid", session.session_id.as_str());
if op == "check_sms_code" {
params.insert("checkfortos", "0");
params.insert("skipvoip", "1");
}
let resp = self.client
.post("https://steamcommunity.com/steamguard/phoneajax")
.header(COOKIE, cookies.cookies(&url).unwrap())
.send()
.unwrap();
let result: Value = resp.json().unwrap();
if result["has_phone"] != Value::Null {
trace!("found has_phone field");
return result["has_phone"].as_bool().unwrap();
} else if result["success"] != Value::Null {
trace!("found success field");
return result["success"].as_bool().unwrap();
} else {
trace!("did not find any expected field");
return false;
}
}
}
fn generate_device_id() -> String {
return format!("android:{}", uuid::Uuid::new_v4().to_string());
}
#[derive(Debug, Clone, Deserialize)]
pub struct AddAuthenticatorResponse {
pub response: SteamGuardAccount
}

81
src/accountmanager.rs Normal file
View file

@ -0,0 +1,81 @@
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use serde::{Serialize, Deserialize};
use std::error::Error;
use steamguard_cli::SteamGuardAccount;
use log::*;
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
pub encrypted: bool,
pub entries: Vec<ManifestEntry>,
pub first_run: bool,
pub periodic_checking: bool,
pub periodic_checking_interval: i32,
pub periodic_checking_checkall: bool,
pub auto_confirm_market_transactions: bool,
pub auto_confirm_trades: bool,
#[serde(skip)]
pub accounts: Vec<SteamGuardAccount>,
#[serde(skip)]
folder: String, // I wanted to use a Path here, but it was too hard to make it work...
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub encryption_iv: Option<String>,
pub encryption_salt: Option<String>,
pub filename: String,
#[serde(rename = "steamid")]
pub steam_id: u64,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Manifest, Box<dyn Error>> {
debug!("loading manifest: {:?}", &path);
match File::open(path) {
Ok(file) => {
let reader = BufReader::new(file);
match serde_json::from_reader(reader) {
Ok(m) => {
let mut manifest: Manifest = m;
manifest.folder = String::from(path.parent().unwrap().to_str().unwrap());
return Ok(manifest);
}
Err(e) => {
return Err(Box::new(e));
}
}
}
Err(e) => {
return Err(Box::new(e));
}
}
}
pub fn load_accounts(&mut self) {
for entry in &self.entries {
let path = Path::new(&self.folder).join(&entry.filename);
debug!("loading account: {:?}", path);
match File::open(path) {
Ok(f) => {
let reader = BufReader::new(f);
match serde_json::from_reader(reader) {
Ok(a) => {
let account: SteamGuardAccount = a;
self.accounts.push(account);
}
Err(e) => {
error!("invalid json: {}", e)
}
}
}
Err(e) => {
error!("unable to open account: {}", e)
}
}
}
}
}

39
src/confirmation.rs Normal file
View file

@ -0,0 +1,39 @@
/// A mobile confirmation. There are multiple things that can be confirmed, like trade offers.
#[derive(Debug, Clone, Copy)]
pub struct Confirmation {
pub id: u64,
pub key: u64,
/// Comes from the `data-type` attribute in the HTML
pub int_type: i32,
/// Trade offer ID or market transaction ID
pub creator: u64,
pub conf_type: ConfirmationType,
}
impl Confirmation {
/// Human readable representation of this confirmation.
pub fn description(&self) -> String {
format!("{:?} id={} key={}", self.conf_type, self.id, self.key)
}
}
#[derive(Debug, Clone, Copy)]
pub enum ConfirmationType {
Generic = 1,
Trade = 2,
MarketSell = 3,
AccountRecovery = 6,
Unknown
}
impl From<&str> for ConfirmationType {
fn from(text: &str) -> Self {
match text {
"1" => ConfirmationType::Generic,
"2" => ConfirmationType::Trade,
"3" => ConfirmationType::MarketSell,
"6" => ConfirmationType::AccountRecovery,
_ => ConfirmationType::Unknown,
}
}
}

299
src/lib.rs Normal file
View file

@ -0,0 +1,299 @@
use std::{collections::HashMap, convert::TryInto, thread, time};
use anyhow::Result;
pub use confirmation::{Confirmation, ConfirmationType};
use hmacsha1::hmac_sha1;
use regex::Regex;
use reqwest::{Url, cookie::CookieStore, header::{COOKIE, USER_AGENT}};
use serde::{Serialize, Deserialize};
use log::*;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate anyhow;
pub mod steamapi;
mod confirmation;
// const STEAMAPI_BASE: String = "https://api.steampowered.com";
// const COMMUNITY_BASE: String = "https://steamcommunity.com";
// const MOBILEAUTH_BASE: String = STEAMAPI_BASE + "/IMobileAuthService/%s/v0001";
// static MOBILEAUTH_GETWGTOKEN: String = MOBILEAUTH_BASE.Replace("%s", "GetWGToken");
// const TWO_FACTOR_BASE: String = STEAMAPI_BASE + "/ITwoFactorService/%s/v0001";
// static TWO_FACTOR_TIME_QUERY: String = TWO_FACTOR_BASE.Replace("%s", "QueryTime");
lazy_static! {
static ref CONFIRMATION_REGEX: Regex = Regex::new("<div class=\"mobileconf_list_entry\" id=\"conf[0-9]+\" data-confid=\"(\\d+)\" data-key=\"(\\d+)\" data-type=\"(\\d+)\" data-creator=\"(\\d+)\"").unwrap();
static ref CONFIRMATION_DESCRIPTION_REGEX: Regex = Regex::new("<div>((Confirm|Trade|Account recovery|Sell -) .+)</div>").unwrap();
}
extern crate hmacsha1;
extern crate base64;
extern crate cookie;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SteamGuardAccount {
pub account_name: String,
pub serial_number: String,
pub revocation_code: String,
pub shared_secret: String,
pub token_gid: String,
pub identity_secret: String,
pub server_time: u64,
pub uri: String,
pub fully_enrolled: bool,
pub device_id: String,
#[serde(rename = "Session")]
pub session: Option<steamapi::Session>,
}
fn build_time_bytes(mut time: i64) -> [u8; 8] {
let mut bytes: [u8; 8] = [0; 8];
for i in (0..8).rev() {
bytes[i] = time as u8;
time >>= 8;
}
return bytes
}
pub fn parse_shared_secret(secret: String) -> [u8; 20] {
if secret.len() == 0 {
panic!("unable to parse empty shared secret")
}
match base64::decode(secret) {
Result::Ok(v) => {
return v.try_into().unwrap()
}
_ => {
panic!("unable to parse shared secret")
}
}
}
fn generate_confirmation_hash_for_time(time: i64, tag: &str, identity_secret: &String) -> String {
let decode: &[u8] = &base64::decode(&identity_secret).unwrap();
let time_bytes = build_time_bytes(time);
let tag_bytes = tag.as_bytes();
let array = [&time_bytes, tag_bytes].concat();
let hash = hmac_sha1(decode, &array);
let encoded = base64::encode(hash);
return encoded;
}
impl SteamGuardAccount {
pub fn new() -> Self {
return SteamGuardAccount{
account_name: String::from(""),
serial_number: String::from(""),
revocation_code: String::from(""),
shared_secret: String::from(""),
token_gid: String::from(""),
identity_secret: String::from(""),
server_time: 0,
uri: String::from(""),
fully_enrolled: false,
device_id: String::from(""),
session: Option::None,
}
}
pub fn generate_code(&self, time: i64) -> String {
let steam_guard_code_translations: [u8; 26] = [50, 51, 52, 53, 54, 55, 56, 57, 66, 67, 68, 70, 71, 72, 74, 75, 77, 78, 80, 81, 82, 84, 86, 87, 88, 89];
let time_bytes: [u8; 8] = build_time_bytes(time / 30i64);
let shared_secret: [u8; 20] = parse_shared_secret(self.shared_secret.clone());
// println!("time_bytes: {:?}", time_bytes);
let hashed_data = hmacsha1::hmac_sha1(&shared_secret, &time_bytes);
// println!("hashed_data: {:?}", hashed_data);
let mut code_array: [u8; 5] = [0; 5];
let b = (hashed_data[19] & 0xF) as usize;
let mut code_point: i32 =
((hashed_data[b] & 0x7F) as i32) << 24 |
((hashed_data[b + 1] & 0xFF) as i32) << 16 |
((hashed_data[b + 2] & 0xFF) as i32) << 8 |
((hashed_data[b + 3] & 0xFF) as i32);
for i in 0..5 {
code_array[i] = steam_guard_code_translations[code_point as usize % steam_guard_code_translations.len()];
code_point /= steam_guard_code_translations.len() as i32;
}
// println!("code_array: {:?}", code_array);
return String::from_utf8(code_array.iter().map(|c| *c).collect()).unwrap()
}
fn get_confirmation_query_params(&self, tag: &str) -> HashMap<&str, String> {
let session = self.session.clone().unwrap();
let time = steamapi::get_server_time();
let mut params = HashMap::new();
params.insert("p", self.device_id.clone());
params.insert("a", session.steam_id.to_string());
params.insert("k", generate_confirmation_hash_for_time(time, tag, &self.identity_secret));
params.insert("t", time.to_string());
params.insert("m", String::from("android"));
params.insert("tag", String::from(tag));
return params;
}
fn build_cookie_jar(&self) -> reqwest::cookie::Jar {
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = reqwest::cookie::Jar::default();
let session = self.session.clone().unwrap();
let session_id = session.session_id;
cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url);
cookies.add_cookie_str("mobileClient=android", &url);
cookies.add_cookie_str("Steam_Language=english", &url);
cookies.add_cookie_str("dob=", &url);
cookies.add_cookie_str(format!("sessionid={}", session_id).as_str(), &url);
cookies.add_cookie_str(format!("steamid={}", session.steam_id).as_str(), &url);
cookies.add_cookie_str(format!("steamLogin={}", session.steam_login).as_str(), &url);
cookies.add_cookie_str(format!("steamLoginSecure={}", session.steam_login_secure).as_str(), &url);
return cookies;
}
pub fn get_trade_confirmations(&self) -> Result<Vec<Confirmation>, anyhow::Error> {
// uri: "https://steamcommunity.com/mobileconf/conf"
// confirmation details:
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = self.build_cookie_jar();
let client = reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()?;
match client
.get("https://steamcommunity.com/mobileconf/conf".parse::<Url>().unwrap())
.header("X-Requested-With", "com.valvesoftware.android.steam.community")
.header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
.header(COOKIE, cookies.cookies(&url).unwrap())
.query(&self.get_confirmation_query_params("conf"))
.send() {
Ok(resp) => {
trace!("{:?}", resp);
let text = resp.text().unwrap();
trace!("text: {:?}", text);
println!("{}", text);
// possible errors:
//
// Invalid authenticator:
// <div>Invalid authenticator</div>
// <div>It looks like your Steam Guard Mobile Authenticator is providing incorrect Steam Guard codes. This could be caused by an inaccurate clock or bad timezone settings on your device. If your time settings are correct, it could be that a different device has been set up to provide the Steam Guard codes for your account, which means the authenticator on this device is no longer valid.</div>
//
// <div>Nothing to confirm</div>
match CONFIRMATION_REGEX.captures(text.as_str()) {
Some(caps) => {
let conf_id = caps[1].parse()?;
let conf_key = caps[2].parse()?;
let conf_type = caps[3].try_into().unwrap_or(ConfirmationType::Unknown);
let conf_creator = caps[4].parse()?;
debug!("conf_id={} conf_key={} conf_type={:?} conf_creator={}", conf_id, conf_key, conf_type, conf_creator);
return Ok(vec![Confirmation {
id: conf_id,
key: conf_key,
conf_type: conf_type,
creator: conf_creator,
int_type: 0,
}]);
}
_ => {
info!("No confirmations");
return Ok(vec![]);
}
};
}
Err(e) => {
error!("error: {:?}", e);
bail!(e);
}
}
}
/// Respond to a confirmation.
///
/// Host: https://steamcommunity.com
/// Steam Endpoint: `GET /mobileconf/ajaxop`
fn send_confirmation_ajax(&self, conf: &Confirmation, operation: String) -> anyhow::Result<()> {
ensure!(operation == "allow" || operation == "cancel");
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = self.build_cookie_jar();
let client = reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()?;
let mut query_params = self.get_confirmation_query_params("conf");
query_params.insert("op", operation);
query_params.insert("cid", conf.id.to_string());
query_params.insert("ck", conf.key.to_string());
#[derive(Debug, Clone, Copy, Deserialize)]
struct SendConfirmationResponse {
pub success: bool
}
let resp: SendConfirmationResponse = client.get("https://steamcommunity.com/mobileconf/ajaxop".parse::<Url>().unwrap())
.header("X-Requested-With", "com.valvesoftware.android.steam.community")
.header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
.header(COOKIE, cookies.cookies(&url).unwrap())
.query(&query_params)
.send()?
.json()?;
ensure!(resp.success);
Ok(())
}
pub fn accept_confirmation(&self, conf: &Confirmation) -> anyhow::Result<()> {
self.send_confirmation_ajax(conf, "allow".into())
}
pub fn deny_confirmation(&self, conf: &Confirmation) -> anyhow::Result<()> {
self.send_confirmation_ajax(conf, "cancel".into())
}
/// Steam Endpoint: `GET /mobileconf/details/:id`
pub fn get_confirmation_details(&self, conf: &Confirmation) -> anyhow::Result<String> {
#[derive(Debug, Clone, Deserialize)]
struct ConfirmationDetailsResponse {
pub success: bool,
pub html: String,
}
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = self.build_cookie_jar();
let client = reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()?;
let query_params = self.get_confirmation_query_params("details");
let resp: ConfirmationDetailsResponse = client.get(format!("https://steamcommunity.com/mobileconf/details/{}", conf.id).parse::<Url>().unwrap())
.header("X-Requested-With", "com.valvesoftware.android.steam.community")
.header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
.header(COOKIE, cookies.cookies(&url).unwrap())
.query(&query_params)
.send()?
.json()?;
ensure!(resp.success);
Ok(resp.html)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_code() {
let mut account = SteamGuardAccount::new();
account.shared_secret = String::from("zvIayp3JPvtvX/QGHqsqKBk/44s=");
let code = account.generate_code(1616374841i64);
assert_eq!(code, "2F9J5")
}
#[test]
fn test_generate_confirmation_hash_for_time() {
assert_eq!(generate_confirmation_hash_for_time(1617591917, "conf", &String::from("GQP46b73Ws7gr8GmZFR0sDuau5c=")), String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk="));
}
}

410
src/main.rs Normal file
View file

@ -0,0 +1,410 @@
extern crate rpassword;
use borrow::BorrowMut;
use collections::HashSet;
use io::{Write, stdout};
use steamapi::Session;
use steamguard_cli::*;
use termion::{color::Color, raw::IntoRawMode, screen::AlternateScreen};
use ::std::*;
use text_io::read;
use std::{convert::TryInto, io::stdin, path::Path, sync::Arc};
use clap::{App, Arg, crate_version};
use log::*;
use regex::Regex;
use termion::event::{Key, Event};
use termion::input::{TermRead};
#[macro_use]
extern crate lazy_static;
mod accountmanager;
mod accountlinker;
lazy_static! {
static ref CAPTCHA_VALID_CHARS: Regex = Regex::new("^([A-H]|[J-N]|[P-R]|[T-Z]|[2-4]|[7-9]|[@%&])+$").unwrap();
}
fn main() {
let matches = 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")
.help("Select the account you want by steam username. By default, the first account in the manifest is selected.")
)
.arg(
Arg::with_name("all")
.long("all")
.short("a")
.takes_value(false)
.help("Select all accounts in the manifest.")
)
.arg(
Arg::with_name("mafiles-path")
.long("mafiles-path")
.short("m")
.default_value("~/maFiles")
.help("Specify which folder your maFiles are in.")
)
.arg(
Arg::with_name("passkey")
.long("passkey")
.short("p")
.help("Specify your encryption passkey.")
)
.arg(
Arg::with_name("verbosity")
.short("v")
.help("Log what is going on verbosely.")
.takes_value(false)
.multiple(true)
)
.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.")
)
)
.subcommand(
App::new("setup")
.about("Set up a new account with steamguard-cli")
)
.subcommand(
App::new("debug")
.arg(
Arg::with_name("demo-conf-menu")
.help("Show an example confirmation menu using dummy data.")
.takes_value(false)
)
)
.get_matches();
let verbosity = matches.occurrences_of("verbosity") as usize + 2;
stderrlog::new()
.verbosity(verbosity)
.module(module_path!()).init().unwrap();
if let Some(demo_matches) = matches.subcommand_matches("debug") {
if demo_matches.is_present("demo-conf-menu") {
demo_confirmation_menu();
}
return;
}
let path = Path::new(matches.value_of("mafiles-path").unwrap()).join("manifest.json");
let mut manifest: accountmanager::Manifest;
match accountmanager::Manifest::load(path.as_path()) {
Ok(m) => {
manifest = m;
}
Err(e) => {
error!("Could not load manifest: {}", e);
return;
}
}
manifest.load_accounts();
if matches.is_present("setup") {
info!("setup");
let mut linker = accountlinker::AccountLinker::new();
do_login(&mut linker.account);
// linker.link(linker.account.session.expect("no login session"));
return;
}
let mut selected_accounts: Vec<SteamGuardAccount> = vec![];
if matches.is_present("all") {
// manifest.accounts.iter().map(|a| selected_accounts.push(a.b));
for account in manifest.accounts {
selected_accounts.push(account.clone());
}
} else {
for account in manifest.accounts {
if !matches.is_present("username") {
selected_accounts.push(account.clone());
break;
}
if matches.value_of("username").unwrap() == account.account_name {
selected_accounts.push(account.clone());
break;
}
}
}
debug!("selected accounts: {:?}", selected_accounts.iter().map(|a| a.account_name.clone()).collect::<Vec<String>>());
if let Some(trade_matches) = matches.subcommand_matches("trade") {
info!("trade");
for a in selected_accounts.iter_mut() {
let mut account = a; // why is this necessary?
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);
}
}
}
if trade_matches.is_present("accept-all") {
info!("accepting all confirmations");
for conf in &confirmations {
let result = account.accept_confirmation(conf);
debug!("accept confirmation result: {:?}", result);
}
}
else {
if termion::is_tty(&stdout()) {
let (accept, deny) = prompt_confirmation_menu(confirmations);
for conf in &accept {
let result = account.accept_confirmation(conf);
debug!("accept confirmation result: {:?}", result);
}
for conf in &deny {
let result = account.deny_confirmation(conf);
debug!("deny confirmation result: {:?}", result);
}
}
else {
warn!("not a tty, not showing menu");
for conf in &confirmations {
println!("{}", conf.description());
}
}
}
}
} else {
let server_time = steamapi::get_server_time();
for account in selected_accounts {
trace!("{:?}", account);
let code = account.generate_code(server_time);
println!("{}", code);
}
}
}
fn validate_captcha_text(text: &String) -> bool {
return CAPTCHA_VALID_CHARS.is_match(text);
}
#[test]
fn test_validate_captcha_text() {
assert!(validate_captcha_text(&String::from("2WWUA@")));
assert!(validate_captcha_text(&String::from("3G8HT2")));
assert!(validate_captcha_text(&String::from("3J%@X3")));
assert!(validate_captcha_text(&String::from("2GCZ4A")));
assert!(validate_captcha_text(&String::from("3G8HT2")));
assert!(!validate_captcha_text(&String::from("asd823")));
assert!(!validate_captcha_text(&String::from("!PQ4RD")));
assert!(!validate_captcha_text(&String::from("1GQ4XZ")));
assert!(!validate_captcha_text(&String::from("8GO4XZ")));
assert!(!validate_captcha_text(&String::from("IPQ4RD")));
assert!(!validate_captcha_text(&String::from("0PT4RD")));
assert!(!validate_captcha_text(&String::from("APTSRD")));
assert!(!validate_captcha_text(&String::from("AP5TRD")));
assert!(!validate_captcha_text(&String::from("AP6TRD")));
}
/// Prompt the user for text input.
fn prompt() -> String {
let mut text = String::new();
let _ = std::io::stdout().flush();
stdin().read_line(&mut text).expect("Did not enter a correct string");
return String::from(text.strip_suffix('\n').unwrap());
}
fn prompt_captcha_text(captcha_gid: &String) -> String {
println!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid);
let mut captcha_text;
loop {
print!("Enter captcha text: ");
captcha_text = prompt();
if captcha_text.len() > 0 && validate_captcha_text(&captcha_text) {
break;
}
warn!("Invalid chars for captcha text found in user's input. Prompting again...");
}
return captcha_text;
}
/// Returns a tuple of (accepted, denied). Ignored confirmations are not included.
fn prompt_confirmation_menu(confirmations: Vec<Confirmation>) -> (Vec<Confirmation>, Vec<Confirmation>) {
println!("press a key other than enter to show the menu.");
let mut to_accept_idx: HashSet<usize> = HashSet::new();
let mut to_deny_idx: HashSet<usize> = HashSet::new();
let mut screen = AlternateScreen::from(stdout().into_raw_mode().unwrap());
let stdin = stdin();
let mut selected_idx = 0;
for c in stdin.events() {
match c.expect("could not get events") {
Event::Key(Key::Char('a')) => {
to_accept_idx.insert(selected_idx);
to_deny_idx.remove(&selected_idx);
}
Event::Key(Key::Char('d')) => {
to_accept_idx.remove(&selected_idx);
to_deny_idx.insert(selected_idx);
}
Event::Key(Key::Char('i')) => {
to_accept_idx.remove(&selected_idx);
to_deny_idx.remove(&selected_idx);
}
Event::Key(Key::Char('A')) => {
(0..confirmations.len()).for_each(|i| { to_accept_idx.insert(i); to_deny_idx.remove(&i); });
}
Event::Key(Key::Char('D')) => {
(0..confirmations.len()).for_each(|i| { to_accept_idx.remove(&i); to_deny_idx.insert(i); });
}
Event::Key(Key::Char('I')) => {
(0..confirmations.len()).for_each(|i| { to_accept_idx.remove(&i); to_deny_idx.remove(&i); });
}
Event::Key(Key::Up) if selected_idx > 0 => {
selected_idx -= 1;
}
Event::Key(Key::Down) if selected_idx < confirmations.len() - 1 => {
selected_idx += 1;
}
Event::Key(Key::Char('\n')) => {
break;
}
Event::Key(Key::Esc) | Event::Key(Key::Ctrl('c')) => {
return (vec![], vec![]);
}
_ => {}
}
write!(screen, "{}{}{}arrow keys to select, [a]ccept, [d]eny, [i]gnore, [enter] confirm choices\n\n", termion::clear::All, termion::cursor::Goto(1, 1), termion::color::Fg(termion::color::White)).unwrap();
for i in 0..confirmations.len() {
if selected_idx == i {
write!(screen, "\r{} >", termion::color::Fg(termion::color::LightYellow)).unwrap();
}
else {
write!(screen, "\r{} ", termion::color::Fg(termion::color::White)).unwrap();
}
if to_accept_idx.contains(&i) {
write!(screen, "{}[a]", termion::color::Fg(termion::color::LightGreen)).unwrap();
}
else if to_deny_idx.contains(&i) {
write!(screen, "{}[d]", termion::color::Fg(termion::color::LightRed)).unwrap();
}
else {
write!(screen, "[ ]").unwrap();
}
if selected_idx == i {
write!(screen, "{}", termion::color::Fg(termion::color::LightYellow)).unwrap();
}
write!(screen, " {}\n", confirmations[i].description()).unwrap();
}
}
return (
to_accept_idx.iter().map(|i| confirmations[*i]).collect(),
to_deny_idx.iter().map(|i| confirmations[*i]).collect(),
);
}
fn do_login(account: &mut SteamGuardAccount) {
if account.account_name.len() > 0 {
println!("Username: {}", account.account_name);
} else {
print!("Username: ");
account.account_name = prompt();
}
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");
}
// TODO: reprompt if password is empty
let mut login = steamapi::UserLogin::new(account.account_name.clone(), password);
let mut loops = 0;
loop {
match login.login() {
steamapi::LoginResult::Ok(s) => {
account.session = Option::Some(s);
break;
}
steamapi::LoginResult::Need2FA => {
let server_time = steamapi::get_server_time();
login.twofactor_code = account.generate_code(server_time);
}
steamapi::LoginResult::NeedCaptcha{ captcha_gid } => {
login.captcha_text = prompt_captcha_text(&captcha_gid);
}
steamapi::LoginResult::NeedEmail => {
println!("You should have received an email with a code.");
print!("Enter code");
login.email_code = prompt();
}
r => {
error!("Fatal login result: {:?}", r);
return;
}
}
loops += 1;
if loops > 2 {
error!("Too many loops. Aborting login process, to avoid getting rate limited.");
return;
}
}
}
fn demo_confirmation_menu() {
info!("showing demo menu");
let (accept, deny) = prompt_confirmation_menu(vec![
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::Trade,
int_type: 0,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::MarketSell,
int_type: 0,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::AccountRecovery,
int_type: 0,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::Trade,
int_type: 0,
creator: 09870987,
},
]);
println!("accept: {}, deny: {}", accept.len(), deny.len());
}

363
src/steamapi.rs Normal file
View file

@ -0,0 +1,363 @@
use std::collections::HashMap;
use reqwest::{Url, cookie::{CookieStore}, header::COOKIE, header::{SET_COOKIE, USER_AGENT}};
use rsa::{PublicKey, RsaPublicKey};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Serialize, Deserialize};
use serde::de::{Visitor};
use rand::rngs::OsRng;
use std::fmt;
use log::*;
#[derive(Debug, Clone, Deserialize)]
struct LoginResponse {
success: bool,
#[serde(default)]
login_complete: bool,
// #[serde(default)]
// oauth: String,
#[serde(default)]
captcha_needed: bool,
#[serde(default)]
captcha_gid: String,
#[serde(default)]
emailsteamid: u64,
#[serde(default)]
emailauth_needed: bool,
#[serde(default)]
requires_twofactor: bool,
#[serde(default)]
message: String,
transfer_urls: Option<Vec<String>>,
transfer_parameters: Option<LoginTransferParameters>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LoginTransferParameters {
steamid: String,
token_secure: String,
auth: String,
remember_login: bool,
webcookie: String,
}
#[derive(Debug, Clone, Deserialize)]
struct RsaResponse {
success: bool,
publickey_exp: String,
publickey_mod: String,
timestamp: String,
token_gid: String,
}
#[derive(Debug)]
pub enum LoginResult {
Ok(Session),
BadRSA,
BadCredentials,
NeedCaptcha{ captcha_gid: String },
Need2FA,
NeedEmail,
TooManyAttempts,
OtherFailure,
}
#[derive(Debug)]
pub struct UserLogin {
pub username: String,
pub password: String,
pub captcha_required: bool,
pub captcha_gid: String,
pub captcha_text: String,
pub twofactor_code: String,
pub email_code: String,
pub steam_id: u64,
cookies: reqwest::cookie::Jar,
// cookies: Arc<reqwest::cookie::Jar>,
client: reqwest::blocking::Client,
}
impl UserLogin {
pub fn new(username: String, password: String) -> UserLogin {
return UserLogin {
username,
password,
captcha_required: false,
captcha_gid: String::from("-1"),
captcha_text: String::from(""),
twofactor_code: String::from(""),
email_code: String::from(""),
steam_id: 0,
cookies: reqwest::cookie::Jar::default(),
// cookies: Arc::<reqwest::cookie::Jar>::new(reqwest::cookie::Jar::default()),
client: reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()
.unwrap(),
}
}
fn update_session(&self) {
trace!("UserLogin::update_session");
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
self.cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url);
self.cookies.add_cookie_str("mobileClient=android", &url);
self.cookies.add_cookie_str("Steam_Language=english", &url);
let resp = self.client
.get("https://steamcommunity.com/login?oauth_client_id=DE45CD61&oauth_scope=read_profile%20write_profile%20read_client%20write_client".parse::<Url>().unwrap())
.header("X-Requested-With", "com.valvesoftware.android.steam.community")
.header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
// .header(COOKIE, "mobileClientVersion=0 (2.1.3)")
// .header(COOKIE, "mobileClient=android")
// .header(COOKIE, "Steam_Language=english")
.header(COOKIE, self.cookies.cookies(&url).unwrap())
.send();
trace!("{:?}", resp);
trace!("cookies: {:?}", self.cookies);
}
pub fn login(&mut self) -> LoginResult {
trace!("UserLogin::login");
if self.captcha_required && self.captcha_text.len() == 0 {
return LoginResult::NeedCaptcha{captcha_gid: self.captcha_gid.clone()};
}
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
if self.cookies.cookies(&url) == Option::None {
self.update_session()
}
let mut params = HashMap::new();
params.insert("donotcache", format!("{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() * 1000));
params.insert("username", self.username.clone());
let resp = self.client
.post("https://steamcommunity.com/login/getrsakey")
.form(&params)
.send()
.unwrap();
let encrypted_password: String;
let rsa_timestamp: String;
match resp.json::<RsaResponse>() {
Ok(rsa_resp) => {
rsa_timestamp = rsa_resp.timestamp.clone();
encrypted_password = encrypt_password(rsa_resp, &self.password);
}
Err(error) => {
error!("rsa error: {:?}", error);
return LoginResult::BadRSA
}
}
trace!("captchagid: {}", self.captcha_gid);
trace!("captcha_text: {}", self.captcha_text);
trace!("twofactorcode: {}", self.twofactor_code);
trace!("emailauth: {}", self.email_code);
let mut params = HashMap::new();
params.insert("donotcache", format!("{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() * 1000));
params.insert("username", self.username.clone());
params.insert("password", encrypted_password);
params.insert("twofactorcode", self.twofactor_code.clone());
params.insert("emailauth", self.email_code.clone());
params.insert("captchagid", self.captcha_gid.clone());
params.insert("captcha_text", self.captcha_text.clone());
params.insert("rsatimestamp", rsa_timestamp);
params.insert("remember_login", String::from("true"));
params.insert("oauth_client_id", String::from("DE45CD61"));
params.insert("oauth_scope", String::from("read_profile write_profile read_client write_client"));
let login_resp: LoginResponse;
match self.client
.post("https://steamcommunity.com/login/dologin")
.form(&params)
.send() {
Ok(resp) => {
// https://stackoverflow.com/questions/49928648/rubys-mechanize-error-401-while-sending-a-post-request-steam-trade-offer-send
let text = resp.text().unwrap();
trace!("resp content: {}", text);
match serde_json::from_str(text.as_str()) {
Ok(lr) => {
info!("login resp: {:?}", lr);
login_resp = lr;
}
Err(error) => {
debug!("login response did not have normal schema");
error!("login parse error: {:?}", error);
return LoginResult::OtherFailure;
}
}
}
Err(error) => {
error!("login request error: {:?}", error);
return LoginResult::OtherFailure;
}
}
if login_resp.message.contains("too many login") {
return LoginResult::TooManyAttempts;
}
if login_resp.message.contains("Incorrect login") {
return LoginResult::BadCredentials;
}
if login_resp.captcha_needed {
self.captcha_gid = login_resp.captcha_gid.clone();
return LoginResult::NeedCaptcha{ captcha_gid: self.captcha_gid.clone() };
}
if login_resp.emailauth_needed {
self.steam_id = login_resp.emailsteamid.clone();
return LoginResult::NeedEmail;
}
if login_resp.requires_twofactor {
return LoginResult::Need2FA;
}
if !login_resp.login_complete {
return LoginResult::BadCredentials;
}
// transfer login parameters? Not completely sure what this is for.
// i guess steam changed their authentication scheme slightly
let oauth;
match (login_resp.transfer_urls, login_resp.transfer_parameters) {
(Some(urls), Some(params)) => {
debug!("received transfer parameters, relaying data...");
for url in urls {
trace!("posting transfer to {}", url);
let result = self.client
.post(url)
.json(&params)
.send();
trace!("result: {:?}", result);
match result {
Ok(resp) => {
debug!("result status: {}", resp.status());
self.save_cookies_from_response(&resp);
}
Err(e) => {
error!("failed to transfer parameters: {:?}", e);
}
}
}
oauth = OAuthData {
oauth_token: params.auth,
steamid: params.steamid.parse().unwrap(),
wgtoken: params.token_secure.clone(), // guessing
wgtoken_secure: params.token_secure,
webcookie: params.webcookie,
};
}
_ => {
error!("did not receive transfer_urls and transfer_parameters");
return LoginResult::OtherFailure;
}
}
// let oauth: OAuthData = serde_json::from_str(login_resp.oauth.as_str()).unwrap();
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
let cookies = self.cookies.cookies(&url).unwrap();
let all_cookies = cookies.to_str().unwrap();
let mut session_id = String::from("");
for cookie in all_cookies.split(";").map(|s| cookie::Cookie::parse(s).unwrap()) {
if cookie.name() == "sessionid" {
session_id = String::from(cookie.value());
}
}
trace!("cookies {:?}", cookies);
let session = self.build_session(oauth, session_id);
return LoginResult::Ok(session);
}
fn build_session(&self, data: OAuthData, session_id: String) -> Session {
return Session{
token: data.oauth_token,
steam_id: data.steamid,
steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken),
steam_login_secure: format!("{}%7C%7C{}", data.steamid, data.wgtoken_secure),
session_id: session_id,
web_cookie: data.webcookie,
};
}
fn save_cookies_from_response(&mut self, response: &reqwest::blocking::Response) {
let set_cookie_iter = response.headers().get_all(SET_COOKIE);
let url = "https://steamcommunity.com".parse::<Url>().unwrap();
for c in set_cookie_iter {
c.to_str()
.into_iter()
.for_each(|cookie_str| {
self.cookies.add_cookie_str(cookie_str, &url)
});
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct OAuthData {
oauth_token: String,
steamid: u64,
wgtoken: String,
wgtoken_secure: String,
webcookie: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
#[serde(rename = "SessionID")]
pub session_id: String,
#[serde(rename = "SteamLogin")]
pub steam_login: String,
#[serde(rename = "SteamLoginSecure")]
pub steam_login_secure: String,
#[serde(rename = "WebCookie")]
pub web_cookie: String,
#[serde(rename = "OAuthToken")]
pub token: String,
#[serde(rename = "SteamID")]
pub steam_id: u64,
}
pub fn get_server_time() -> i64 {
let client = reqwest::blocking::Client::new();
let resp = client
.post("https://api.steampowered.com/ITwoFactorService/QueryTime/v0001")
.body("steamid=0")
.send();
let value: serde_json::Value = resp.unwrap().json().unwrap();
// println!("{}", value["response"]);
return String::from(value["response"]["server_time"].as_str().unwrap()).parse().unwrap();
}
fn encrypt_password(rsa_resp: RsaResponse, password: &String) -> String {
let rsa_exponent = rsa::BigUint::parse_bytes(rsa_resp.publickey_exp.as_bytes(), 16).unwrap();
let rsa_modulus = rsa::BigUint::parse_bytes(rsa_resp.publickey_mod.as_bytes(), 16).unwrap();
let public_key = RsaPublicKey::new(rsa_modulus, rsa_exponent).unwrap();
let mut rng = OsRng;
let padding = rsa::PaddingScheme::new_pkcs1v15_encrypt();
let encrypted_password = base64::encode(public_key.encrypt(&mut rng, padding, password.as_bytes()).unwrap());
return encrypted_password;
}
#[test]
fn test_encrypt_password() {
let rsa_resp = RsaResponse{
success: true,
publickey_exp: String::from("010001"),
publickey_mod: String::from("98f9088c1250b17fe19d2b2422d54a1eef0036875301731f11bd17900e215318eb6de1546727c0b7b61b86cefccdcb2f8108c813154d9a7d55631965eece810d4ab9d8a59c486bda778651b876176070598a93c2325c275cb9c17bdbcacf8edc9c18c0c5d59bc35703505ef8a09ed4c62b9f92a3fac5740ce25e490ab0e26d872140e4103d912d1e3958f844264211277ee08d2b4dd3ac58b030b25342bd5c949ae7794e46a8eab26d5a8deca683bfd381da6c305b19868b8c7cd321ce72c693310a6ebf2ecd43642518f825894602f6c239cf193cb4346ce64beac31e20ef88f934f2f776597734bb9eae1ebdf4a453973b6df9d5e90777bffe5db83dd1757b"),
timestamp: String::from("asdf"),
token_gid: String::from("asdf"),
};
let result = encrypt_password(rsa_resp, &String::from("kelwleofpsm3n4ofc"));
assert_eq!(result.len(), 344); // can't test exact match because the result is different every time (because of OsRng)
}