diff --git a/Cargo.lock b/Cargo.lock index 80a1ce1..09b3e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "adler" version = "1.0.2" @@ -1241,6 +1243,26 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "steamguard" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "cookie", + "hmac-sha1", + "lazy_static 1.4.0", + "log", + "rand", + "regex", + "reqwest", + "rsa", + "serde", + "serde_json", + "standback", + "uuid", +] + [[package]] name = "steamguard-cli" version = "0.2.0" @@ -1261,6 +1283,7 @@ dependencies = [ "serde_json", "standback", "stderrlog", + "steamguard", "termion", "text_io", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 88bdf7e..8026e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,9 @@ +[workspace] + +members = [ + "steamguard" +] + [package] name = "steamguard-cli" version = "0.2.0" @@ -26,3 +32,4 @@ regex = "1" lazy_static = "1.4.0" uuid = { version = "0.8", features = ["v4"] } termion = "1.5.6" +steamguard = { path = "./steamguard" } diff --git a/src/accountlinker.rs b/src/accountlinker.rs index 12faa1f..5e039c6 100644 --- a/src/accountlinker.rs +++ b/src/accountlinker.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use reqwest::{Url, cookie::{CookieStore}, header::COOKIE, header::{SET_COOKIE, USER_AGENT}}; -use serde::{Serialize, Deserialize}; +use reqwest::{Url, cookie::{CookieStore}, header::COOKIE}; +use serde::Deserialize; use serde_json::Value; -use steamguard_cli::{SteamGuardAccount, steamapi::Session}; +use steamguard::{SteamGuardAccount, steamapi::Session}; use log::*; #[derive(Debug, Clone)] diff --git a/src/accountmanager.rs b/src/accountmanager.rs index 8c36dea..625095f 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -3,7 +3,7 @@ use std::io::BufReader; use std::path::Path; use serde::{Serialize, Deserialize}; use std::error::Error; -use steamguard_cli::SteamGuardAccount; +use steamguard::SteamGuardAccount; use log::*; #[derive(Debug, Serialize, Deserialize)] diff --git a/src/main.rs b/src/main.rs index 98bed8f..15278e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,11 @@ 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 steamguard::{SteamGuardAccount, Confirmation, ConfirmationType, steamapi}; +use std::collections::HashSet; +use std::{io::{Write, stdout, stdin}, path::Path}; use clap::{App, Arg, crate_version}; use log::*; use regex::Regex; -use termion::event::{Key, Event}; -use termion::input::{TermRead}; +use termion::{raw::IntoRawMode, screen::AlternateScreen, event::{Key, Event}, input::{TermRead}}; #[macro_use] extern crate lazy_static; @@ -345,18 +338,18 @@ fn do_login(account: &mut SteamGuardAccount) { let mut loops = 0; loop { match login.login() { - steamapi::LoginResult::Ok(s) => { + Ok(s) => { account.session = Option::Some(s); break; } - steamapi::LoginResult::Need2FA => { + Err(steamapi::LoginError::Need2FA) => { let server_time = steamapi::get_server_time(); login.twofactor_code = account.generate_code(server_time); } - steamapi::LoginResult::NeedCaptcha{ captcha_gid } => { + Err(steamapi::LoginError::NeedCaptcha{ captcha_gid }) => { login.captcha_text = prompt_captcha_text(&captcha_gid); } - steamapi::LoginResult::NeedEmail => { + Err(steamapi::LoginError::NeedEmail) => { println!("You should have received an email with a code."); print!("Enter code"); login.email_code = prompt(); diff --git a/steamguard/Cargo.toml b/steamguard/Cargo.toml new file mode 100644 index 0000000..d31c89c --- /dev/null +++ b/steamguard/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "steamguard" +version = "0.1.0" +edition = "2018" +description = "Library for generating 2fa codes for Steam and responding to mobile confirmations." + +# 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" +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 +cookie = "0.14" +regex = "1" +lazy_static = "1.4.0" +uuid = { version = "0.8", features = ["v4"] } +log = "0.4.14" diff --git a/src/confirmation.rs b/steamguard/src/confirmation.rs similarity index 100% rename from src/confirmation.rs rename to steamguard/src/confirmation.rs diff --git a/src/lib.rs b/steamguard/src/lib.rs similarity index 94% rename from src/lib.rs rename to steamguard/src/lib.rs index b51c5ac..c5acf98 100644 --- a/src/lib.rs +++ b/steamguard/src/lib.rs @@ -46,27 +46,14 @@ pub struct SteamGuardAccount { pub session: Option, } -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 +fn build_time_bytes(time: i64) -> [u8; 8] { + return time.to_be_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") - } - } +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()); } fn generate_confirmation_hash_for_time(time: i64, tag: &str, identity_secret: &String) -> String { @@ -99,11 +86,10 @@ impl SteamGuardAccount { 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()); - // println!("time_bytes: {:?}", time_bytes); + let shared_secret: [u8; 20] = parse_shared_secret(self.shared_secret.clone()).unwrap(); 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 = @@ -117,8 +103,6 @@ impl SteamGuardAccount { 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() } @@ -283,6 +267,13 @@ impl SteamGuardAccount { mod tests { 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_generate_code() { let mut account = SteamGuardAccount::new(); diff --git a/src/steamapi.rs b/steamguard/src/steamapi.rs similarity index 89% rename from src/steamapi.rs rename to steamguard/src/steamapi.rs index a594013..f97022e 100644 --- a/src/steamapi.rs +++ b/steamguard/src/steamapi.rs @@ -3,9 +3,6 @@ use reqwest::{Url, cookie::{CookieStore}, header::COOKIE, header::{SET_COOKIE, U 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)] @@ -13,8 +10,6 @@ struct LoginResponse { success: bool, #[serde(default)] login_complete: bool, - // #[serde(default)] - // oauth: String, #[serde(default)] captcha_needed: bool, #[serde(default)] @@ -50,8 +45,7 @@ struct RsaResponse { } #[derive(Debug)] -pub enum LoginResult { - Ok(Session), +pub enum LoginError { BadRSA, BadCredentials, NeedCaptcha{ captcha_gid: String }, @@ -73,7 +67,6 @@ pub struct UserLogin { pub steam_id: u64, cookies: reqwest::cookie::Jar, - // cookies: Arc, client: reqwest::blocking::Client, } @@ -89,7 +82,6 @@ impl UserLogin { email_code: String::from(""), steam_id: 0, cookies: reqwest::cookie::Jar::default(), - // cookies: Arc::::new(reqwest::cookie::Jar::default()), client: reqwest::blocking::ClientBuilder::new() .cookie_store(true) .build() @@ -97,6 +89,7 @@ impl UserLogin { } } + /// 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::().unwrap(); @@ -118,10 +111,10 @@ impl UserLogin { trace!("cookies: {:?}", self.cookies); } - pub fn login(&mut self) -> LoginResult { + pub fn login(&mut self) -> anyhow::Result { trace!("UserLogin::login"); if self.captcha_required && self.captcha_text.len() == 0 { - return LoginResult::NeedCaptcha{captcha_gid: self.captcha_gid.clone()}; + return Err(LoginError::NeedCaptcha{captcha_gid: self.captcha_gid.clone()}); } let url = "https://steamcommunity.com".parse::().unwrap(); @@ -147,7 +140,7 @@ impl UserLogin { } Err(error) => { error!("rsa error: {:?}", error); - return LoginResult::BadRSA + return Err(LoginError::BadRSA); } } @@ -185,40 +178,40 @@ impl UserLogin { Err(error) => { debug!("login response did not have normal schema"); error!("login parse error: {:?}", error); - return LoginResult::OtherFailure; + return Err(LoginError::OtherFailure); } } } Err(error) => { error!("login request error: {:?}", error); - return LoginResult::OtherFailure; + return Err(LoginError::OtherFailure); } } if login_resp.message.contains("too many login") { - return LoginResult::TooManyAttempts; + return Err(LoginError::TooManyAttempts); } if login_resp.message.contains("Incorrect login") { - return LoginResult::BadCredentials; + return Err(LoginError::BadCredentials); } if login_resp.captcha_needed { self.captcha_gid = login_resp.captcha_gid.clone(); - return LoginResult::NeedCaptcha{ captcha_gid: self.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 LoginResult::NeedEmail; + return Err(LoginError::NeedEmail); } if login_resp.requires_twofactor { - return LoginResult::Need2FA; + return Err(LoginError::Need2FA); } if !login_resp.login_complete { - return LoginResult::BadCredentials; + return Err(LoginError::BadCredentials); } @@ -256,11 +249,10 @@ impl UserLogin { } _ => { error!("did not receive transfer_urls and transfer_parameters"); - return LoginResult::OtherFailure; + return Err(LoginError::OtherFailure); } } - // let oauth: OAuthData = serde_json::from_str(login_resp.oauth.as_str()).unwrap(); let url = "https://steamcommunity.com".parse::().unwrap(); let cookies = self.cookies.cookies(&url).unwrap(); let all_cookies = cookies.to_str().unwrap(); @@ -273,7 +265,7 @@ impl UserLogin { trace!("cookies {:?}", cookies); let session = self.build_session(oauth, session_id); - return LoginResult::Ok(session); + return Ok(session); } fn build_session(&self, data: OAuthData, session_id: String) -> Session { @@ -334,8 +326,6 @@ pub fn get_server_time() -> i64 { .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(); } @@ -343,7 +333,10 @@ 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; + #[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; @@ -359,5 +352,6 @@ fn test_encrypt_password() { 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) + assert_eq!(result.len(), 344); + assert_eq!(result, "RUo/3IfbkVcJi1q1S5QlpKn1mEn3gNJoc/Z4VwxRV9DImV6veq/YISEuSrHB3885U5MYFLn1g94Y+cWRL6HGXoV+gOaVZe43m7O92RwiVz6OZQXMfAv3UC/jcqn/xkitnj+tNtmx55gCxmGbO2KbqQ0TQqAyqCOOw565B+Cwr2OOorpMZAViv9sKA/G3Q6yzscU6rhua179c8QjC1Hk3idUoSzpWfT4sHNBW/EREXZ3Dkjwu17xzpfwIUpnBVIlR8Vj3coHgUCpTsKVRA3T814v9BYPlvLYwmw5DW3ddx+2SyTY0P5uuog36TN2PqYS7ioF5eDe16gyfRR4Nzn/7wA=="); }