Merge pull request #77 from dyc3/reorganize

Reorganize to make managing cookies easier
This commit is contained in:
Carson McManus 2021-08-08 12:57:48 -04:00 committed by GitHub
commit 8a3408b405
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1321 additions and 1112 deletions

64
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,64 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'steamguard'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=steamguard"
],
"filter": {
"name": "steamguard",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'steamguard-cli'",
"cargo": {
"args": [
"build",
"--bin=steamguard-cli",
"--package=steamguard-cli"
],
"filter": {
"name": "steamguard-cli",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'steamguard-cli'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=steamguard-cli",
"--package=steamguard-cli"
],
"filter": {
"name": "steamguard-cli",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

7
Cargo.lock generated
View file

@ -689,6 +689,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.10.1"
@ -1610,6 +1616,7 @@ dependencies = [
"hmac-sha1",
"lazy_static 1.4.0",
"log",
"maplit",
"rand 0.8.4",
"regex",
"reqwest",

3
rustfmt.toml Normal file
View file

@ -0,0 +1,3 @@
tab_spaces = 4
hard_tabs = true
normalize_comments = true

View file

@ -7,81 +7,81 @@ use steamguard::{steamapi::Session, SteamGuardAccount};
#[derive(Debug, Clone)]
pub struct AccountLinker {
device_id: String,
phone_number: String,
pub account: SteamGuardAccount,
client: reqwest::blocking::Client,
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 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"));
}
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 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);
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 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 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;
}
}
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());
return format!("android:{}", uuid::Uuid::new_v4().to_string());
}
#[derive(Debug, Clone, Deserialize)]
pub struct AddAuthenticatorResponse {
pub response: SteamGuardAccount,
pub response: SteamGuardAccount,
}

View file

@ -8,87 +8,87 @@ use steamguard::SteamGuardAccount;
#[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,
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<Arc<Mutex<SteamGuardAccount>>>,
#[serde(skip)]
folder: String, // I wanted to use a Path here, but it was too hard to make it work...
#[serde(skip)]
pub accounts: Vec<Arc<Mutex<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,
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) -> anyhow::Result<Manifest> {
debug!("loading manifest: {:?}", &path);
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut manifest: Manifest = serde_json::from_reader(reader)?;
manifest.folder = String::from(path.parent().unwrap().to_str().unwrap());
return Ok(manifest);
}
pub fn load(path: &Path) -> anyhow::Result<Manifest> {
debug!("loading manifest: {:?}", &path);
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut manifest: Manifest = serde_json::from_reader(reader)?;
manifest.folder = String::from(path.parent().unwrap().to_str().unwrap());
return Ok(manifest);
}
pub fn load_accounts(&mut self) -> anyhow::Result<()> {
for entry in &self.entries {
let path = Path::new(&self.folder).join(&entry.filename);
debug!("loading account: {:?}", path);
let file = File::open(path)?;
let reader = BufReader::new(file);
let account: SteamGuardAccount = serde_json::from_reader(reader)?;
self.accounts.push(Arc::new(Mutex::new(account)));
}
Ok(())
}
pub fn load_accounts(&mut self) -> anyhow::Result<()> {
for entry in &self.entries {
let path = Path::new(&self.folder).join(&entry.filename);
debug!("loading account: {:?}", path);
let file = File::open(path)?;
let reader = BufReader::new(file);
let account: SteamGuardAccount = serde_json::from_reader(reader)?;
self.accounts.push(Arc::new(Mutex::new(account)));
}
Ok(())
}
pub fn add_account(&mut self, account: SteamGuardAccount) {
debug!("adding account to manifest: {}", account.account_name);
let steamid = account.session.clone().unwrap().steam_id;
self.entries.push(ManifestEntry {
filename: format!("{}.maFile", &account.account_name),
steam_id: steamid,
encryption_iv: None,
encryption_salt: None,
});
self.accounts.push(Arc::new(Mutex::new(account)));
}
pub fn add_account(&mut self, account: SteamGuardAccount) {
debug!("adding account to manifest: {}", account.account_name);
let steamid = account.session.clone().unwrap().steam_id;
self.entries.push(ManifestEntry {
filename: format!("{}.maFile", &account.account_name),
steam_id: steamid,
encryption_iv: None,
encryption_salt: None,
});
self.accounts.push(Arc::new(Mutex::new(account)));
}
pub fn save(&self) -> anyhow::Result<()> {
ensure!(
self.entries.len() == self.accounts.len(),
"Manifest entries don't match accounts."
);
for (entry, account) in self.entries.iter().zip(&self.accounts) {
debug!("saving {}", entry.filename);
let serialized = serde_json::to_string(account.as_ref())?;
ensure!(
serialized.len() > 2,
"Something extra weird happened and the account was serialized into nothing."
);
let path = Path::new(&self.folder).join(&entry.filename);
let mut file = File::create(path)?;
file.write_all(serialized.as_bytes())?;
file.sync_data()?;
}
debug!("saving manifest");
let manifest_serialized = serde_json::to_string(&self)?;
let path = Path::new(&self.folder).join("manifest.json");
let mut file = File::create(path)?;
file.write_all(manifest_serialized.as_bytes())?;
file.sync_data()?;
Ok(())
}
pub fn save(&self) -> anyhow::Result<()> {
ensure!(
self.entries.len() == self.accounts.len(),
"Manifest entries don't match accounts."
);
for (entry, account) in self.entries.iter().zip(&self.accounts) {
debug!("saving {}", entry.filename);
let serialized = serde_json::to_string(account.as_ref())?;
ensure!(
serialized.len() > 2,
"Something extra weird happened and the account was serialized into nothing."
);
let path = Path::new(&self.folder).join(&entry.filename);
let mut file = File::create(path)?;
file.write_all(serialized.as_bytes())?;
file.sync_data()?;
}
debug!("saving manifest");
let manifest_serialized = serde_json::to_string(&self)?;
let path = Path::new(&self.folder).join("manifest.json");
let mut file = File::create(path)?;
file.write_all(manifest_serialized.as_bytes())?;
file.sync_data()?;
Ok(())
}
}

View file

@ -4,16 +4,18 @@ use log::*;
use regex::Regex;
use std::collections::HashSet;
use std::{
io::{stdin, stdout, Write},
path::Path,
sync::{Arc, Mutex},
io::{stdin, stdout, Write},
path::Path,
sync::{Arc, Mutex},
};
use steamguard::{
steamapi, Confirmation, ConfirmationType, LoginError, SteamGuardAccount, UserLogin,
};
use steamguard::{steamapi, Confirmation, ConfirmationType, SteamGuardAccount};
use termion::{
event::{Event, Key},
input::TermRead,
raw::IntoRawMode,
screen::AlternateScreen,
event::{Event, Key},
input::TermRead,
raw::IntoRawMode,
screen::AlternateScreen,
};
#[macro_use]
@ -24,12 +26,12 @@ mod accountlinker;
mod accountmanager;
lazy_static! {
static ref CAPTCHA_VALID_CHARS: Regex =
Regex::new("^([A-H]|[J-N]|[P-R]|[T-Z]|[2-4]|[7-9]|[@%&])+$").unwrap();
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")
let matches = App::new("steamguard-cli")
.version(crate_version!())
.bin_name("steamguard")
.author("dyc3 (Carson McManus)")
@ -92,364 +94,366 @@ fn main() {
)
.get_matches();
let verbosity = matches.occurrences_of("verbosity") as usize + 2;
stderrlog::new()
.verbosity(verbosity)
.module(module_path!())
.init()
.unwrap();
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;
}
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;
}
}
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();
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;
}
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<Arc<Mutex<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.lock().unwrap().account_name {
selected_accounts.push(account.clone());
break;
}
}
}
let mut selected_accounts: Vec<Arc<Mutex<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.lock().unwrap().account_name {
selected_accounts.push(account.clone());
break;
}
}
}
debug!(
"selected accounts: {:?}",
selected_accounts
.iter()
.map(|a| a.lock().unwrap().account_name.clone())
.collect::<Vec<String>>()
);
debug!(
"selected accounts: {:?}",
selected_accounts
.iter()
.map(|a| a.lock().unwrap().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.lock().unwrap();
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<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);
}
}
}
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());
}
}
}
}
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());
}
}
}
}
manifest.save();
} else {
let server_time = steamapi::get_server_time();
for account in selected_accounts {
trace!("{:?}", account);
let code = account.lock().unwrap().generate_code(server_time);
println!("{}", code);
}
}
manifest.save();
} else {
let server_time = steamapi::get_server_time();
for account in selected_accounts {
trace!("{:?}", account);
let code = account.lock().unwrap().generate_code(server_time);
println!("{}", code);
}
}
}
fn validate_captcha_text(text: &String) -> bool {
return CAPTCHA_VALID_CHARS.is_match(text);
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")));
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());
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;
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>,
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();
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 screen = AlternateScreen::from(stdout().into_raw_mode().unwrap());
let stdin = stdin();
let mut selected_idx = 0;
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![]);
}
_ => {}
}
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();
}
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 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();
}
if selected_idx == i {
write!(
screen,
"{}",
termion::color::Fg(termion::color::LightYellow)
)
.unwrap();
}
write!(screen, " {}\n", confirmations[i].description()).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(),
);
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() {
Ok(s) => {
account.session = Option::Some(s);
break;
}
Err(steamapi::LoginError::Need2FA) => {
let server_time = steamapi::get_server_time();
login.twofactor_code = account.generate_code(server_time);
}
Err(steamapi::LoginError::NeedCaptcha { captcha_gid }) => {
login.captcha_text = prompt_captcha_text(&captcha_gid);
}
Err(steamapi::LoginError::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;
}
}
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 = UserLogin::new(account.account_name.clone(), password);
let mut loops = 0;
loop {
match login.login() {
Ok(s) => {
account.session = Option::Some(s);
break;
}
Err(LoginError::Need2FA) => {
debug!("generating 2fa code and retrying");
let server_time = steamapi::get_server_time();
login.twofactor_code = account.generate_code(server_time);
}
Err(LoginError::NeedCaptcha { captcha_gid }) => {
debug!("need captcha to log in");
login.captcha_text = prompt_captcha_text(&captcha_gid);
}
Err(LoginError::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,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::MarketSell,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::AccountRecovery,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::Trade,
creator: 09870987,
},
]);
println!("accept: {}, deny: {}", accept.len(), deny.len());
info!("showing demo menu");
let (accept, deny) = prompt_confirmation_menu(vec![
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::Trade,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::MarketSell,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::AccountRecovery,
creator: 09870987,
},
Confirmation {
id: 1234,
key: 12345,
conf_type: ConfirmationType::Trade,
creator: 09870987,
},
]);
println!("accept: {}, deny: {}", accept.len(), deny.len());
}

View file

@ -22,3 +22,4 @@ lazy_static = "1.4.0"
uuid = { version = "0.8", features = ["v4"] }
log = "0.4.14"
scraper = "0.12.0"
maplit = "1.0.2"

View file

@ -1,37 +1,37 @@
/// A mobile confirmation. There are multiple things that can be confirmed, like trade offers.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Confirmation {
pub id: u64,
pub key: u64,
/// Trade offer ID or market transaction ID
pub creator: u64,
pub conf_type: ConfirmationType,
pub id: u64,
pub key: u64,
/// 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)
}
/// 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, PartialEq, Eq)]
pub enum ConfirmationType {
Generic = 1,
Trade = 2,
MarketSell = 3,
AccountRecovery = 6,
Unknown,
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,
}
}
fn from(text: &str) -> Self {
match text {
"1" => ConfirmationType::Generic,
"2" => ConfirmationType::Trade,
"3" => ConfirmationType::MarketSell,
"6" => ConfirmationType::AccountRecovery,
_ => ConfirmationType::Unknown,
}
}
}

View file

@ -0,0 +1 @@
{"success":true,"requires_twofactor":false,"redirect_uri":"steammobile://mobileloginsucceeded","login_complete":true,"oauth":"{\"steamid\":\"78562647129469312\",\"account_name\":\"feuarus\",\"oauth_token\":\"fd2fdb3d0717bad2220d98c7ec61c7bd\",\"wgtoken\":\"72E7013D598A4F68C7E268F6FA3767D89D763732\",\"wgtoken_secure\":\"21061EA13C36D7C29812CAED900A215171AD13A2\",\"webcookie\":\"6298070A226E5DAD49938D78BCF36F7A7118FDD5\"}"}

View file

@ -3,20 +3,24 @@ pub use confirmation::{Confirmation, ConfirmationType};
use hmacsha1::hmac_sha1;
use log::*;
use reqwest::{
cookie::CookieStore,
header::{COOKIE, USER_AGENT},
Url,
cookie::CookieStore,
header::{COOKIE, USER_AGENT},
Url,
};
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, convert::TryInto, thread, time};
pub use userlogin::{LoginError, UserLogin};
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate anyhow;
#[macro_use]
extern crate maplit;
mod confirmation;
pub mod steamapi;
mod userlogin;
// const STEAMAPI_BASE: String = "https://api.steampowered.com";
// const COMMUNITY_BASE: String = "https://steamcommunity.com";
@ -31,128 +35,128 @@ extern crate hmacsha1;
#[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>,
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(time: i64) -> [u8; 8] {
return time.to_be_bytes();
return time.to_be_bytes();
}
pub fn parse_shared_secret(secret: String) -> anyhow::Result<[u8; 20]> {
ensure!(secret.len() != 0, "unable to parse empty shared secret");
let result = base64::decode(secret)?.try_into();
return Ok(result.unwrap());
ensure!(secret.len() != 0, "unable to parse empty shared secret");
let result = base64::decode(secret)?.try_into();
return Ok(result.unwrap());
}
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;
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 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,
];
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,
];
// this effectively makes it so that it creates a new code every 30 seconds.
let time_bytes: [u8; 8] = build_time_bytes(time / 30i64);
let shared_secret: [u8; 20] = parse_shared_secret(self.shared_secret.clone()).unwrap();
let hashed_data = hmacsha1::hmac_sha1(&shared_secret, &time_bytes);
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);
// this effectively makes it so that it creates a new code every 30 seconds.
let time_bytes: [u8; 8] = build_time_bytes(time / 30i64);
let shared_secret: [u8; 20] = parse_shared_secret(self.shared_secret.clone()).unwrap();
let hashed_data = hmacsha1::hmac_sha1(&shared_secret, &time_bytes);
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;
}
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;
}
return String::from_utf8(code_array.iter().map(|c| *c).collect()).unwrap();
}
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 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;
}
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()?;
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()?;
let resp = client
let resp = 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")
@ -160,37 +164,37 @@ impl SteamGuardAccount {
.query(&self.get_confirmation_query_params("conf"))
.send()?;
trace!("{:?}", resp);
let text = resp.text().unwrap();
trace!("text: {:?}", text);
println!("{}", text);
return parse_confirmations(text);
}
trace!("{:?}", resp);
let text = resp.text().unwrap();
trace!("text: {:?}", text);
println!("{}", text);
return parse_confirmations(text);
}
/// 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");
/// 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 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());
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,
}
#[derive(Debug, Clone, Copy, Deserialize)]
struct SendConfirmationResponse {
pub success: bool,
}
let resp: SendConfirmationResponse = client.get("https://steamcommunity.com/mobileconf/ajaxop".parse::<Url>().unwrap())
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())
@ -198,35 +202,35 @@ impl SteamGuardAccount {
.send()?
.json()?;
ensure!(resp.success);
Ok(())
}
ensure!(resp.success);
Ok(())
}
pub fn accept_confirmation(&self, conf: &Confirmation) -> anyhow::Result<()> {
self.send_confirmation_ajax(conf, "allow".into())
}
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())
}
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,
}
/// 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 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 query_params = self.get_confirmation_query_params("details");
let resp: ConfirmationDetailsResponse = client.get(format!("https://steamcommunity.com/mobileconf/details/{}", conf.id).parse::<Url>().unwrap())
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())
@ -234,125 +238,125 @@ impl SteamGuardAccount {
.send()?
.json()?;
ensure!(resp.success);
Ok(resp.html)
}
ensure!(resp.success);
Ok(resp.html)
}
}
fn parse_confirmations(text: String) -> anyhow::Result<Vec<Confirmation>> {
// 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>
// 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>
let fragment = Html::parse_fragment(&text);
let selector = Selector::parse(".mobileconf_list_entry").unwrap();
let mut confirmations = vec![];
for elem in fragment.select(&selector) {
let conf = Confirmation {
id: elem.value().attr("data-confid").unwrap().parse()?,
key: elem.value().attr("data-key").unwrap().parse()?,
conf_type: elem
.value()
.attr("data-type")
.unwrap()
.try_into()
.unwrap_or(ConfirmationType::Unknown),
creator: elem.value().attr("data-creator").unwrap().parse()?,
};
confirmations.push(conf);
}
return Ok(confirmations);
let fragment = Html::parse_fragment(&text);
let selector = Selector::parse(".mobileconf_list_entry").unwrap();
let mut confirmations = vec![];
for elem in fragment.select(&selector) {
let conf = Confirmation {
id: elem.value().attr("data-confid").unwrap().parse()?,
key: elem.value().attr("data-key").unwrap().parse()?,
conf_type: elem
.value()
.attr("data-type")
.unwrap()
.try_into()
.unwrap_or(ConfirmationType::Unknown),
creator: elem.value().attr("data-creator").unwrap().parse()?,
};
confirmations.push(conf);
}
return Ok(confirmations);
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn test_build_time_bytes() {
let t1 = build_time_bytes(1617591917i64);
let t2: [u8; 8] = [0, 0, 0, 0, 96, 106, 126, 109];
assert!(
t1.iter().zip(t2.iter()).all(|(a, b)| a == b),
"Arrays are not equal, got {:?}",
t1
);
}
#[test]
fn test_build_time_bytes() {
let t1 = build_time_bytes(1617591917i64);
let t2: [u8; 8] = [0, 0, 0, 0, 96, 106, 126, 109];
assert!(
t1.iter().zip(t2.iter()).all(|(a, b)| a == b),
"Arrays are not equal, got {:?}",
t1
);
}
#[test]
fn test_generate_code() {
let mut account = SteamGuardAccount::new();
account.shared_secret = String::from("zvIayp3JPvtvX/QGHqsqKBk/44s=");
#[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")
}
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=")
);
}
#[test]
fn test_generate_confirmation_hash_for_time() {
assert_eq!(
generate_confirmation_hash_for_time(
1617591917,
"conf",
&String::from("GQP46b73Ws7gr8GmZFR0sDuau5c=")
),
String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk=")
);
}
#[test]
fn test_parse_multiple_confirmations() {
let text = include_str!("fixtures/confirmations/multiple-confirmations.html");
let confirmations = parse_confirmations(text.into()).unwrap();
assert_eq!(confirmations.len(), 5);
assert_eq!(
confirmations[0],
Confirmation {
id: 9890792058,
key: 15509106087034649470,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693131245,
}
);
assert_eq!(
confirmations[1],
Confirmation {
id: 9890791666,
key: 2661901169510258722,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693130525,
}
);
assert_eq!(
confirmations[2],
Confirmation {
id: 9890791241,
key: 15784514761287735229,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693129565,
}
);
assert_eq!(
confirmations[3],
Confirmation {
id: 9890790828,
key: 5049250785011653560,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693128685,
}
);
assert_eq!(
confirmations[4],
Confirmation {
id: 9890790159,
key: 6133112455066694993,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693127345,
}
);
}
#[test]
fn test_parse_multiple_confirmations() {
let text = include_str!("fixtures/confirmations/multiple-confirmations.html");
let confirmations = parse_confirmations(text.into()).unwrap();
assert_eq!(confirmations.len(), 5);
assert_eq!(
confirmations[0],
Confirmation {
id: 9890792058,
key: 15509106087034649470,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693131245,
}
);
assert_eq!(
confirmations[1],
Confirmation {
id: 9890791666,
key: 2661901169510258722,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693130525,
}
);
assert_eq!(
confirmations[2],
Confirmation {
id: 9890791241,
key: 15784514761287735229,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693129565,
}
);
assert_eq!(
confirmations[3],
Confirmation {
id: 9890790828,
key: 5049250785011653560,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693128685,
}
);
assert_eq!(
confirmations[4],
Confirmation {
id: 9890790159,
key: 6133112455066694993,
conf_type: ConfirmationType::MarketSell,
creator: 3392884950693127345,
}
);
}
}

View file

@ -1,396 +1,331 @@
use log::*;
use reqwest::{
cookie::CookieStore,
header::COOKIE,
header::{SET_COOKIE, USER_AGENT},
Url,
blocking::RequestBuilder,
cookie::CookieStore,
header::COOKIE,
header::{HeaderMap, HeaderName, HeaderValue, SET_COOKIE},
Url,
};
use rsa::{PublicKey, RsaPublicKey};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use serde::{Deserialize, Deserializer, Serialize};
use std::iter::FromIterator;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
lazy_static! {
static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::<Url>().unwrap();
}
#[derive(Debug, Clone, Deserialize)]
struct LoginResponse {
success: bool,
#[serde(default)]
login_complete: bool,
#[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>,
pub struct LoginResponse {
pub success: bool,
#[serde(default)]
pub login_complete: bool,
#[serde(default)]
pub captcha_needed: bool,
#[serde(default)]
pub captcha_gid: String,
#[serde(default)]
pub emailsteamid: u64,
#[serde(default)]
pub emailauth_needed: bool,
#[serde(default)]
pub requires_twofactor: bool,
#[serde(default)]
pub message: String,
// #[serde(rename = "oauth")]
// oauth_raw: String,
#[serde(default, deserialize_with = "oauth_data_from_string")]
oauth: Option<OAuthData>,
transfer_urls: Option<Vec<String>>,
transfer_parameters: Option<LoginTransferParameters>,
}
/// For some reason, the `oauth` field in the login response is a string of JSON, not a JSON object.
/// Deserializes to `Option` because the `oauth` field is not always there.
fn oauth_data_from_string<'de, D>(deserializer: D) -> Result<Option<OAuthData>, D::Error>
where
D: Deserializer<'de>,
{
// for some reason, deserializing to &str doesn't work but this does.
let s: String = Deserialize::deserialize(deserializer)?;
let data: OAuthData = serde_json::from_str(s.as_str()).map_err(serde::de::Error::custom)?;
Ok(Some(data))
}
impl LoginResponse {
pub fn needs_transfer_login(&self) -> bool {
self.transfer_urls.is_some() || self.transfer_parameters.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LoginTransferParameters {
steamid: String,
token_secure: String,
auth: String,
remember_login: bool,
webcookie: String,
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 LoginError {
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,
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(),
client: reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.build()
.unwrap(),
};
}
/// Updates the cookie jar with the session cookies by pinging steam servers.
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) -> anyhow::Result<Session, LoginError> {
trace!("UserLogin::login");
if self.captcha_required && self.captcha_text.len() == 0 {
return Err(LoginError::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 Err(LoginError::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 Err(LoginError::OtherFailure);
}
}
}
Err(error) => {
error!("login request error: {:?}", error);
return Err(LoginError::OtherFailure);
}
}
if login_resp.message.contains("too many login") {
return Err(LoginError::TooManyAttempts);
}
if login_resp.message.contains("Incorrect login") {
return Err(LoginError::BadCredentials);
}
if login_resp.captcha_needed {
self.captcha_gid = login_resp.captcha_gid.clone();
return Err(LoginError::NeedCaptcha {
captcha_gid: self.captcha_gid.clone(),
});
}
if login_resp.emailauth_needed {
self.steam_id = login_resp.emailsteamid.clone();
return Err(LoginError::NeedEmail);
}
if login_resp.requires_twofactor {
return Err(LoginError::Need2FA);
}
if !login_resp.login_complete {
return Err(LoginError::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 Err(LoginError::OtherFailure);
}
}
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 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));
}
}
pub struct RsaResponse {
pub success: bool,
pub publickey_exp: String,
pub publickey_mod: String,
pub timestamp: String,
pub token_gid: String,
}
#[derive(Debug, Clone, Deserialize)]
struct OAuthData {
oauth_token: String,
steamid: u64,
wgtoken: String,
wgtoken_secure: String,
webcookie: String,
pub struct OAuthData {
oauth_token: String,
steamid: String,
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,
#[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();
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();
return String::from(value["response"]["server_time"].as_str().unwrap())
.parse()
.unwrap();
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();
#[cfg(test)]
let mut rng = rand::rngs::mock::StepRng::new(2, 1);
#[cfg(not(test))]
let mut rng = rand::rngs::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;
/// Provides raw access to the Steam API. Handles cookies, some deserialization, etc. to make it easier.
#[derive(Debug)]
pub struct SteamApiClient {
cookies: reqwest::cookie::Jar,
client: reqwest::blocking::Client,
pub session: Option<Session>,
}
impl SteamApiClient {
pub fn new() -> SteamApiClient {
SteamApiClient {
cookies: reqwest::cookie::Jar::default(),
client: reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.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")
.default_headers(HeaderMap::from_iter(hashmap! {
HeaderName::from_str("X-Requested-With").expect("could not build default request headers") => HeaderValue::from_str("com.valvesoftware.android.steam.community").expect("could not build default request headers")
}.into_iter()))
.build()
.unwrap(),
session: None,
}
}
fn build_session(&self, data: &OAuthData) -> Session {
return Session {
token: data.oauth_token.clone(),
steam_id: data.steamid.parse().unwrap(),
steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken),
steam_login_secure: format!("{}%7C%7C{}", data.steamid, data.wgtoken_secure),
session_id: self.extract_session_id().unwrap(),
web_cookie: data.webcookie.clone(),
};
}
fn extract_session_id(&self) -> Option<String> {
let cookies = self.cookies.cookies(&STEAM_COOKIE_URL).unwrap();
let all_cookies = cookies.to_str().unwrap();
for cookie in all_cookies
.split(";")
.map(|s| cookie::Cookie::parse(s).unwrap())
{
if cookie.name() == "sessionid" {
return Some(cookie.value().into());
}
}
return None;
}
pub fn save_cookies_from_response(&mut self, response: &reqwest::blocking::Response) {
let set_cookie_iter = response.headers().get_all(SET_COOKIE);
for c in set_cookie_iter {
c.to_str()
.into_iter()
.for_each(|cookie_str| self.cookies.add_cookie_str(cookie_str, &STEAM_COOKIE_URL));
}
}
pub fn request<U: reqwest::IntoUrl>(&self, method: reqwest::Method, url: U) -> RequestBuilder {
self.cookies
.add_cookie_str("mobileClientVersion=0 (2.1.3)", &STEAM_COOKIE_URL);
self.cookies
.add_cookie_str("mobileClient=android", &STEAM_COOKIE_URL);
self.cookies
.add_cookie_str("Steam_Language=english", &STEAM_COOKIE_URL);
self.client
.request(method, url)
.header(COOKIE, self.cookies.cookies(&STEAM_COOKIE_URL).unwrap())
}
pub fn get<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::GET, url)
}
pub fn post<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::POST, url)
}
/// Updates the cookie jar with the session cookies by pinging steam servers.
pub fn update_session(&mut self) -> anyhow::Result<()> {
trace!("SteamApiClient::update_session");
let resp = self
.get("https://steamcommunity.com/login?oauth_client_id=DE45CD61&oauth_scope=read_profile%20write_profile%20read_client%20write_client".parse::<Url>().unwrap())
.send()?;
self.save_cookies_from_response(&resp);
trace!("{:?}", resp);
trace!("cookies: {:?}", self.cookies);
Ok(())
}
/// Endpoint: POST /login/dologin
pub fn login(
&mut self,
username: String,
encrypted_password: String,
twofactor_code: String,
email_code: String,
captcha_gid: String,
captcha_text: String,
rsa_timestamp: String,
) -> anyhow::Result<LoginResponse> {
let params = hashmap! {
"donotcache" => format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
* 1000
),
"username" => username,
"password" => encrypted_password,
"twofactorcode" => twofactor_code,
"emailauth" => email_code,
"captchagid" => captcha_gid,
"captcha_text" => captcha_text,
"rsatimestamp" => rsa_timestamp,
"remember_login" => "true".into(),
"oauth_client_id" => "DE45CD61".into(),
"oauth_scope" => "read_profile write_profile read_client write_client".into(),
};
let resp = self
.post("https://steamcommunity.com/login/dologin")
.form(&params)
.send()?;
let text = resp.text()?;
trace!("raw login response: {}", text);
let login_resp: LoginResponse = serde_json::from_str(text.as_str())?;
if let Some(oauth) = &login_resp.oauth {
self.session = Some(self.build_session(&oauth));
}
return Ok(login_resp);
}
/// A secondary step in the login flow. Does not seem to always be needed?
/// Endpoints: provided by `login()`
pub fn transfer_login(&mut self, login_resp: LoginResponse) -> anyhow::Result<OAuthData> {
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 resp = self.client.post(url).json(&params).send()?;
self.save_cookies_from_response(&resp);
}
let 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,
};
self.session = Some(self.build_session(&oauth));
return Ok(oauth);
}
(None, None) => {
bail!("did not receive transfer_urls and transfer_parameters");
}
(_, None) => {
bail!("did not receive transfer_parameters");
}
(None, _) => {
bail!("did not receive transfer_urls");
}
}
}
}
#[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);
assert_eq!(result, "RUo/3IfbkVcJi1q1S5QlpKn1mEn3gNJoc/Z4VwxRV9DImV6veq/YISEuSrHB3885U5MYFLn1g94Y+cWRL6HGXoV+gOaVZe43m7O92RwiVz6OZQXMfAv3UC/jcqn/xkitnj+tNtmx55gCxmGbO2KbqQ0TQqAyqCOOw565B+Cwr2OOorpMZAViv9sKA/G3Q6yzscU6rhua179c8QjC1Hk3idUoSzpWfT4sHNBW/EREXZ3Dkjwu17xzpfwIUpnBVIlR8Vj3coHgUCpTsKVRA3T814v9BYPlvLYwmw5DW3ddx+2SyTY0P5uuog36TN2PqYS7ioF5eDe16gyfRR4Nzn/7wA==");
fn test_oauth_data_parse() {
// This example is from a login response that did not contain any transfer URLs.
let oauth: OAuthData = serde_json::from_str("{\"steamid\":\"78562647129469312\",\"account_name\":\"feuarus\",\"oauth_token\":\"fd2fdb3d0717bcd2220d98c7ec61c7bd\",\"wgtoken\":\"72E7013D598A4F68C7E268F6FA3767D89D763732\",\"wgtoken_secure\":\"21061EA13C36D7C29812CAED900A215171AD13A2\",\"webcookie\":\"6298070A226E5DAD49938D78BCF36F7A7118FDD5\"}").unwrap();
assert_eq!(oauth.steamid, "78562647129469312");
assert_eq!(oauth.oauth_token, "fd2fdb3d0717bcd2220d98c7ec61c7bd");
assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732");
assert_eq!(
oauth.wgtoken_secure,
"21061EA13C36D7C29812CAED900A215171AD13A2"
);
assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5");
}
#[test]
fn test_login_response_parse() {
let result = serde_json::from_str::<LoginResponse>(include_str!(
"fixtures/api-responses/login-response1.json"
));
assert!(
matches!(result, Ok(_)),
"got error: {}",
result.unwrap_err()
);
let resp = result.unwrap();
let oauth = resp.oauth.unwrap();
assert_eq!(oauth.steamid, "78562647129469312");
assert_eq!(oauth.oauth_token, "fd2fdb3d0717bad2220d98c7ec61c7bd");
assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732");
assert_eq!(
oauth.wgtoken_secure,
"21061EA13C36D7C29812CAED900A215171AD13A2"
);
assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5");
}

190
steamguard/src/userlogin.rs Normal file
View file

@ -0,0 +1,190 @@
use crate::steamapi::{LoginResponse, RsaResponse, Session, SteamApiClient};
use log::*;
use rsa::{PublicKey, RsaPublicKey};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug)]
pub enum LoginError {
BadRSA,
BadCredentials,
NeedCaptcha { captcha_gid: String },
Need2FA,
NeedEmail,
TooManyAttempts,
NetworkFailure(reqwest::Error),
OtherFailure(anyhow::Error),
}
impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{:?}", self)
}
}
impl std::error::Error for LoginError {}
impl From<reqwest::Error> for LoginError {
fn from(err: reqwest::Error) -> Self {
LoginError::NetworkFailure(err)
}
}
impl From<anyhow::Error> for LoginError {
fn from(err: anyhow::Error) -> Self {
LoginError::OtherFailure(err)
}
}
/// Handles the user login flow.
#[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,
client: SteamApiClient,
}
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,
client: SteamApiClient::new(),
};
}
pub fn login(&mut self) -> anyhow::Result<Session, LoginError> {
trace!("UserLogin::login");
if self.captcha_required && self.captcha_text.len() == 0 {
return Err(LoginError::NeedCaptcha {
captcha_gid: self.captcha_gid.clone(),
});
}
if self.client.session.is_none() {
self.client.update_session()?;
}
let params = hashmap! {
"donotcache" => format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
* 1000
),
"username" => self.username.clone(),
};
let resp = self
.client
.post("https://steamcommunity.com/login/getrsakey")
.form(&params)
.send()?;
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 Err(LoginError::BadRSA);
}
}
trace!("captchagid: {}", self.captcha_gid);
trace!("captcha_text: {}", self.captcha_text);
trace!("twofactorcode: {}", self.twofactor_code);
trace!("emailauth: {}", self.email_code);
let login_resp: LoginResponse = self.client.login(
self.username.clone(),
encrypted_password,
self.twofactor_code.clone(),
self.email_code.clone(),
self.captcha_gid.clone(),
self.captcha_text.clone(),
rsa_timestamp,
)?;
if login_resp.message.contains("too many login") {
return Err(LoginError::TooManyAttempts);
}
if login_resp.message.contains("Incorrect login") {
return Err(LoginError::BadCredentials);
}
if login_resp.captcha_needed {
self.captcha_gid = login_resp.captcha_gid.clone();
return Err(LoginError::NeedCaptcha {
captcha_gid: self.captcha_gid.clone(),
});
}
if login_resp.emailauth_needed {
self.steam_id = login_resp.emailsteamid.clone();
return Err(LoginError::NeedEmail);
}
if login_resp.requires_twofactor {
return Err(LoginError::Need2FA);
}
if !login_resp.login_complete {
return Err(LoginError::BadCredentials);
}
if login_resp.needs_transfer_login() {
self.client.transfer_login(login_resp)?;
}
return Ok(self.client.session.clone().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();
#[cfg(test)]
let mut rng = rand::rngs::mock::StepRng::new(2, 1);
#[cfg(not(test))]
let mut rng = rand::rngs::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);
assert_eq!(result, "RUo/3IfbkVcJi1q1S5QlpKn1mEn3gNJoc/Z4VwxRV9DImV6veq/YISEuSrHB3885U5MYFLn1g94Y+cWRL6HGXoV+gOaVZe43m7O92RwiVz6OZQXMfAv3UC/jcqn/xkitnj+tNtmx55gCxmGbO2KbqQ0TQqAyqCOOw565B+Cwr2OOorpMZAViv9sKA/G3Q6yzscU6rhua179c8QjC1Hk3idUoSzpWfT4sHNBW/EREXZ3Dkjwu17xzpfwIUpnBVIlR8Vj3coHgUCpTsKVRA3T814v9BYPlvLYwmw5DW3ddx+2SyTY0P5uuog36TN2PqYS7ioF5eDe16gyfRR4Nzn/7wA==");
}