2023-06-29 10:33:56 -04:00
use std ::borrow ::Cow ;
2023-06-27 10:20:27 -04:00
2023-07-10 11:41:36 -04:00
use base64 ::Engine ;
2023-07-10 10:53:31 -04:00
use hmac ::{ Hmac , Mac } ;
2023-06-27 10:20:27 -04:00
use log ::* ;
use reqwest ::{
cookie ::CookieStore ,
2023-06-29 10:33:56 -04:00
header ::{ CONTENT_TYPE , COOKIE , USER_AGENT } ,
2023-06-27 10:20:27 -04:00
Url ,
} ;
use secrecy ::ExposeSecret ;
2023-06-22 16:20:15 -04:00
use serde ::Deserialize ;
2023-07-10 10:53:31 -04:00
use sha1 ::Sha1 ;
2023-06-22 16:20:15 -04:00
2023-07-02 08:57:13 -04:00
use crate ::{
steamapi ::{ self } ,
transport ::Transport ,
SteamGuardAccount ,
} ;
2023-06-27 10:20:27 -04:00
lazy_static! {
static ref STEAM_COOKIE_URL : Url = " https://steamcommunity.com " . parse ::< Url > ( ) . unwrap ( ) ;
}
/// Provides an interface that wraps the Steam mobile confirmation API.
2023-07-02 08:57:13 -04:00
///
/// Only compatible with WebApiTransport.
pub struct Confirmer < ' a , T > {
2023-06-27 10:20:27 -04:00
account : & ' a SteamGuardAccount ,
2023-07-02 08:57:13 -04:00
transport : T ,
2023-06-27 10:20:27 -04:00
}
2023-07-02 08:57:13 -04:00
impl < ' a , T > Confirmer < ' a , T >
where
T : Transport + Clone ,
{
pub fn new ( transport : T , account : & ' a SteamGuardAccount ) -> Self {
Self { account , transport }
2023-06-27 10:20:27 -04:00
}
2023-06-29 10:33:56 -04:00
fn get_confirmation_query_params < ' q > (
& ' q self ,
tag : & ' q str ,
time : u64 ,
) -> Vec < ( & 'static str , Cow < ' q , str > ) > {
[
( " p " , self . account . device_id . as_str ( ) . into ( ) ) ,
( " a " , self . account . steam_id . to_string ( ) . into ( ) ) ,
(
" k " ,
generate_confirmation_hash_for_time (
time ,
tag ,
self . account . identity_secret . expose_secret ( ) ,
)
. into ( ) ,
2023-06-27 10:20:27 -04:00
) ,
2023-06-29 10:33:56 -04:00
( " t " , time . to_string ( ) . into ( ) ) ,
( " m " , " react " . into ( ) ) ,
( " tag " , tag . into ( ) ) ,
]
. into ( )
2023-06-27 10:20:27 -04:00
}
fn build_cookie_jar ( & self ) -> reqwest ::cookie ::Jar {
let cookies = reqwest ::cookie ::Jar ::default ( ) ;
let tokens = self . account . tokens . as_ref ( ) . unwrap ( ) ;
cookies . add_cookie_str ( " dob= " , & STEAM_COOKIE_URL ) ;
cookies . add_cookie_str (
format! ( " steamid= {} " , self . account . steam_id ) . as_str ( ) ,
& STEAM_COOKIE_URL ,
) ;
cookies . add_cookie_str (
format! (
" steamLoginSecure={}||{} " ,
self . account . steam_id ,
tokens . access_token ( ) . expose_secret ( )
)
. as_str ( ) ,
& STEAM_COOKIE_URL ,
) ;
cookies
}
pub fn get_trade_confirmations ( & self ) -> Result < Vec < Confirmation > , ConfirmerError > {
let cookies = self . build_cookie_jar ( ) ;
2023-07-02 08:57:13 -04:00
let client = self . transport . innner_http_client ( ) ? ;
2023-06-27 10:20:27 -04:00
2023-07-02 08:57:13 -04:00
let time = steamapi ::get_server_time ( self . transport . clone ( ) ) ? . server_time ( ) ;
2023-06-27 10:20:27 -04:00
let resp = client
. get (
" https://steamcommunity.com/mobileconf/getlist "
. parse ::< Url > ( )
. unwrap ( ) ,
)
. header ( USER_AGENT , " steamguard-cli " )
. header ( COOKIE , cookies . cookies ( & STEAM_COOKIE_URL ) . unwrap ( ) )
. query ( & self . get_confirmation_query_params ( " conf " , time ) )
. send ( ) ? ;
trace! ( " {:?} " , resp ) ;
let text = resp . text ( ) . unwrap ( ) ;
debug! ( " Confirmations response: {} " , text ) ;
let mut deser = serde_json ::Deserializer ::from_str ( text . as_str ( ) ) ;
let body : ConfirmationListResponse = serde_path_to_error ::deserialize ( & mut deser ) ? ;
2023-06-27 15:13:26 -04:00
if body . needauth . unwrap_or ( false ) {
2023-06-27 10:20:27 -04:00
return Err ( ConfirmerError ::InvalidTokens ) ;
}
if ! body . success {
2023-09-04 13:10:30 -04:00
return Err ( ConfirmerError ::RemoteFailure ) ;
2023-06-27 10:20:27 -04:00
}
Ok ( body . conf )
}
/// Respond to a confirmation.
///
/// Host: https://steamcommunity.com
/// Steam Endpoint: `GET /mobileconf/ajaxop`
fn send_confirmation_ajax (
& self ,
conf : & Confirmation ,
action : ConfirmationAction ,
) -> Result < ( ) , ConfirmerError > {
2023-06-29 10:33:56 -04:00
debug! ( " responding to a single confirmation: send_confirmation_ajax() " ) ;
2023-06-27 10:20:27 -04:00
let operation = action . to_operation ( ) ;
let cookies = self . build_cookie_jar ( ) ;
2023-07-02 08:57:13 -04:00
let client = self . transport . innner_http_client ( ) ? ;
2023-06-27 10:20:27 -04:00
2023-07-02 08:57:13 -04:00
let time = steamapi ::get_server_time ( self . transport . clone ( ) ) ? . server_time ( ) ;
2023-06-27 10:20:27 -04:00
let mut query_params = self . get_confirmation_query_params ( " conf " , time ) ;
2023-06-29 10:33:56 -04:00
query_params . push ( ( " op " , operation . into ( ) ) ) ;
query_params . push ( ( " cid " , Cow ::Borrowed ( & conf . id ) ) ) ;
query_params . push ( ( " ck " , Cow ::Borrowed ( & conf . nonce ) ) ) ;
2023-06-27 10:20:27 -04:00
let resp = client
. get (
" https://steamcommunity.com/mobileconf/ajaxop "
. parse ::< Url > ( )
. unwrap ( ) ,
)
. header ( USER_AGENT , " steamguard-cli " )
. header ( COOKIE , cookies . cookies ( & STEAM_COOKIE_URL ) . unwrap ( ) )
. query ( & query_params )
. send ( ) ? ;
trace! ( " send_confirmation_ajax() response: {:?} " , & resp ) ;
debug! (
" send_confirmation_ajax() response status code: {} " ,
& resp . status ( )
) ;
let raw = resp . text ( ) ? ;
debug! ( " send_confirmation_ajax() response body: {:?} " , & raw ) ;
let mut deser = serde_json ::Deserializer ::from_str ( raw . as_str ( ) ) ;
let body : SendConfirmationResponse = serde_path_to_error ::deserialize ( & mut deser ) ? ;
if body . needsauth . unwrap_or ( false ) {
return Err ( ConfirmerError ::InvalidTokens ) ;
}
if ! body . success {
2023-09-04 13:10:30 -04:00
return Err ( ConfirmerError ::RemoteFailure ) ;
2023-06-27 10:20:27 -04:00
}
Ok ( ( ) )
}
pub fn accept_confirmation ( & self , conf : & Confirmation ) -> Result < ( ) , ConfirmerError > {
self . send_confirmation_ajax ( conf , ConfirmationAction ::Accept )
}
pub fn deny_confirmation ( & self , conf : & Confirmation ) -> Result < ( ) , ConfirmerError > {
self . send_confirmation_ajax ( conf , ConfirmationAction ::Deny )
}
2023-06-29 10:33:56 -04:00
/// Respond to more than 1 confirmation.
///
/// Host: https://steamcommunity.com
/// Steam Endpoint: `GET /mobileconf/multiajaxop`
fn send_multi_confirmation_ajax (
& self ,
confs : & [ Confirmation ] ,
action : ConfirmationAction ,
) -> Result < ( ) , ConfirmerError > {
debug! ( " responding to bulk confirmations: send_multi_confirmation_ajax() " ) ;
if confs . is_empty ( ) {
debug! ( " confs is empty, nothing to do. " ) ;
return Ok ( ( ) ) ;
}
let operation = action . to_operation ( ) ;
let cookies = self . build_cookie_jar ( ) ;
2023-07-02 08:57:13 -04:00
let client = self . transport . innner_http_client ( ) ? ;
2023-06-29 10:33:56 -04:00
2023-07-02 08:57:13 -04:00
let time = steamapi ::get_server_time ( self . transport . clone ( ) ) ? . server_time ( ) ;
2023-06-29 10:33:56 -04:00
let mut query_params = self . get_confirmation_query_params ( " conf " , time ) ;
query_params . push ( ( " op " , operation . into ( ) ) ) ;
for conf in confs . iter ( ) {
query_params . push ( ( " cid[] " , Cow ::Borrowed ( & conf . id ) ) ) ;
query_params . push ( ( " ck[] " , Cow ::Borrowed ( & conf . nonce ) ) ) ;
}
let query_params = self . build_multi_conf_query_string ( & query_params ) ;
// despite being called query parameters, they will actually go in the body
debug! ( " query_params: {} " , & query_params ) ;
let resp = client
. post (
" https://steamcommunity.com/mobileconf/multiajaxop "
. parse ::< Url > ( )
. unwrap ( ) ,
)
. header ( USER_AGENT , " steamguard-cli " )
. header ( COOKIE , cookies . cookies ( & STEAM_COOKIE_URL ) . unwrap ( ) )
. header (
CONTENT_TYPE ,
" application/x-www-form-urlencoded; charset=UTF-8 " ,
)
. body ( query_params )
. send ( ) ? ;
trace! ( " send_multi_confirmation_ajax() response: {:?} " , & resp ) ;
debug! (
" send_multi_confirmation_ajax() response status code: {} " ,
& resp . status ( )
) ;
let raw = resp . text ( ) ? ;
debug! ( " send_multi_confirmation_ajax() response body: {:?} " , & raw ) ;
let mut deser = serde_json ::Deserializer ::from_str ( raw . as_str ( ) ) ;
let body : SendConfirmationResponse = serde_path_to_error ::deserialize ( & mut deser ) ? ;
if body . needsauth . unwrap_or ( false ) {
return Err ( ConfirmerError ::InvalidTokens ) ;
}
if ! body . success {
2023-09-04 13:10:30 -04:00
return Err ( ConfirmerError ::RemoteFailure ) ;
2023-06-29 10:33:56 -04:00
}
Ok ( ( ) )
}
pub fn accept_confirmations ( & self , confs : & [ Confirmation ] ) -> Result < ( ) , ConfirmerError > {
self . send_multi_confirmation_ajax ( confs , ConfirmationAction ::Accept )
}
pub fn deny_confirmations ( & self , confs : & [ Confirmation ] ) -> Result < ( ) , ConfirmerError > {
self . send_multi_confirmation_ajax ( confs , ConfirmationAction ::Deny )
}
fn build_multi_conf_query_string ( & self , params : & [ ( & str , Cow < str > ) ] ) -> String {
params
. iter ( )
. map ( | ( k , v ) | format! ( " {} = {} " , k , v ) )
. collect ::< Vec < _ > > ( )
. join ( " & " )
}
2023-06-27 10:20:27 -04:00
/// 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 cookies = self . build_cookie_jar ( ) ;
2023-07-02 08:57:13 -04:00
let client = self . transport . innner_http_client ( ) ? ;
2023-06-27 10:20:27 -04:00
2023-07-02 08:57:13 -04:00
let time = steamapi ::get_server_time ( self . transport . clone ( ) ) ? . server_time ( ) ;
2023-06-27 10:20:27 -04:00
let query_params = self . get_confirmation_query_params ( " details " , time ) ;
let resp = client
. get (
format! ( " https://steamcommunity.com/mobileconf/details/ {} " , conf . id )
. parse ::< Url > ( )
. unwrap ( ) ,
)
. header ( USER_AGENT , " steamguard-cli " )
. header ( COOKIE , cookies . cookies ( & STEAM_COOKIE_URL ) . unwrap ( ) )
. query ( & query_params )
. send ( ) ? ;
let text = resp . text ( ) ? ;
let mut deser = serde_json ::Deserializer ::from_str ( text . as_str ( ) ) ;
let body : ConfirmationDetailsResponse = serde_path_to_error ::deserialize ( & mut deser ) ? ;
ensure! ( body . success ) ;
Ok ( body . html )
}
}
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
pub enum ConfirmationAction {
Accept ,
Deny ,
}
impl ConfirmationAction {
fn to_operation ( self ) -> & 'static str {
match self {
ConfirmationAction ::Accept = > " allow " ,
ConfirmationAction ::Deny = > " cancel " ,
}
}
}
#[ derive(Debug, thiserror::Error) ]
pub enum ConfirmerError {
#[ error( " Invalid tokens, login or token refresh required. " ) ]
InvalidTokens ,
#[ error( " Network failure: {0} " ) ]
NetworkFailure ( #[ from ] reqwest ::Error ) ,
#[ error( " Failed to deserialize response: {0} " ) ]
DeserializeError ( #[ from ] serde_path_to_error ::Error < serde_json ::Error > ) ,
2023-09-04 13:10:30 -04:00
#[ error( " Remote failure: Valve's server responded with a failure. This is likely not a steamguard-cli bug, Steam's confirmation API is just unreliable. Wait a bit and try again. " ) ]
RemoteFailure ,
2023-06-27 10:20:27 -04:00
#[ error( " Unknown error: {0} " ) ]
Unknown ( #[ from ] anyhow ::Error ) ,
}
2021-07-27 23:49:53 -04:00
/// A mobile confirmation. There are multiple things that can be confirmed, like trade offers.
2023-06-22 16:20:15 -04:00
#[ derive(Debug, Clone, PartialEq, Eq, Deserialize) ]
2021-07-27 23:49:53 -04:00
pub struct Confirmation {
2023-06-22 16:20:15 -04:00
#[ serde(rename = " type " ) ]
2021-08-08 12:54:46 -04:00
pub conf_type : ConfirmationType ,
2023-06-22 16:20:15 -04:00
pub type_name : String ,
pub id : String ,
/// Trade offer ID or market transaction ID
pub creator_id : String ,
pub nonce : String ,
pub creation_time : u64 ,
pub cancel : String ,
pub accept : String ,
2023-06-26 19:57:17 -04:00
pub icon : Option < String > ,
2023-06-22 16:20:15 -04:00
pub multi : bool ,
pub headline : String ,
pub summary : Vec < String > ,
2021-07-28 14:10:14 -04:00
}
impl Confirmation {
2021-08-08 12:54:46 -04:00
/// Human readable representation of this confirmation.
pub fn description ( & self ) -> String {
2023-06-22 16:20:15 -04:00
format! (
" {:?} - {} - {} " ,
self . conf_type ,
self . headline ,
self . summary . join ( " , " )
)
2021-08-08 12:54:46 -04:00
}
2021-07-27 23:49:53 -04:00
}
2023-06-22 16:20:15 -04:00
#[ derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize) ]
#[ repr(u32) ]
#[ serde(from = " u32 " ) ]
2023-06-29 18:49:03 -04:00
/// Source: <https://github.com/SteamDatabase/SteamTracking/blob/6e7797e69b714c59f4b5784780b24753c17732ba/Structs/enums.steamd#L1607-L1616>
2021-07-27 23:49:53 -04:00
pub enum ConfirmationType {
2023-06-29 15:35:14 -04:00
Test = 1 ,
2021-08-08 12:54:46 -04:00
Trade = 2 ,
MarketSell = 3 ,
2023-06-29 15:35:14 -04:00
FeatureOptOut = 4 ,
PhoneNumberChange = 5 ,
2021-08-08 12:54:46 -04:00
AccountRecovery = 6 ,
2023-06-22 16:20:15 -04:00
Unknown ( u32 ) ,
2021-07-27 23:49:53 -04:00
}
2023-06-22 16:20:15 -04:00
impl From < u32 > for ConfirmationType {
fn from ( text : u32 ) -> Self {
2021-08-08 12:54:46 -04:00
match text {
2023-06-29 15:35:14 -04:00
1 = > ConfirmationType ::Test ,
2023-06-22 16:20:15 -04:00
2 = > ConfirmationType ::Trade ,
3 = > ConfirmationType ::MarketSell ,
2023-06-29 15:35:14 -04:00
4 = > ConfirmationType ::FeatureOptOut ,
5 = > ConfirmationType ::PhoneNumberChange ,
2023-06-22 16:20:15 -04:00
6 = > ConfirmationType ::AccountRecovery ,
v = > ConfirmationType ::Unknown ( v ) ,
2021-08-08 12:54:46 -04:00
}
}
2021-07-27 23:49:53 -04:00
}
2023-06-22 16:20:15 -04:00
#[ derive(Debug, Deserialize) ]
pub struct ConfirmationListResponse {
pub success : bool ,
2023-06-27 10:20:27 -04:00
#[ serde(default) ]
2023-06-27 15:13:26 -04:00
pub needauth : Option < bool > ,
#[ serde(default) ]
2023-06-22 16:20:15 -04:00
pub conf : Vec < Confirmation > ,
}
#[ derive(Debug, Clone, Copy, Deserialize) ]
pub struct SendConfirmationResponse {
pub success : bool ,
2023-06-27 10:20:27 -04:00
#[ serde(default) ]
pub needsauth : Option < bool > ,
}
fn build_time_bytes ( time : u64 ) -> [ u8 ; 8 ] {
time . to_be_bytes ( )
}
fn generate_confirmation_hash_for_time (
time : u64 ,
tag : & str ,
identity_secret : impl AsRef < [ u8 ] > ,
) -> String {
2023-07-10 11:41:36 -04:00
let decode : & [ u8 ] = & base64 ::engine ::general_purpose ::STANDARD
. decode ( identity_secret )
. unwrap ( ) ;
2023-07-10 10:53:31 -04:00
let mut mac = Hmac ::< Sha1 > ::new_from_slice ( decode ) . unwrap ( ) ;
mac . update ( & build_time_bytes ( time ) ) ;
mac . update ( tag . as_bytes ( ) ) ;
let result = mac . finalize ( ) ;
let hash = result . into_bytes ( ) ;
2023-07-10 11:41:36 -04:00
base64 ::engine ::general_purpose ::STANDARD . encode ( hash )
2023-06-22 16:20:15 -04:00
}
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
2023-06-26 19:57:17 -04:00
fn test_parse_confirmations ( ) -> anyhow ::Result < ( ) > {
struct Test {
text : & 'static str ,
confirmation_type : ConfirmationType ,
}
let cases = [
Test {
text : include_str ! ( " fixtures/confirmations/email-change.json " ) ,
confirmation_type : ConfirmationType ::AccountRecovery ,
} ,
Test {
text : include_str ! ( " fixtures/confirmations/phone-number-change.json " ) ,
2023-06-29 15:35:14 -04:00
confirmation_type : ConfirmationType ::PhoneNumberChange ,
2023-06-26 19:57:17 -04:00
} ,
] ;
for case in cases . iter ( ) {
let confirmations = serde_json ::from_str ::< ConfirmationListResponse > ( case . text ) ? ;
2023-06-22 16:20:15 -04:00
2023-06-26 19:57:17 -04:00
assert_eq! ( confirmations . conf . len ( ) , 1 ) ;
2023-06-22 16:20:15 -04:00
2023-06-26 19:57:17 -04:00
let confirmation = & confirmations . conf [ 0 ] ;
2023-06-22 16:20:15 -04:00
2023-06-26 19:57:17 -04:00
assert_eq! ( confirmation . conf_type , case . confirmation_type ) ;
}
2023-06-22 16:20:15 -04:00
Ok ( ( ) )
}
2023-06-27 10:20:27 -04:00
2023-06-27 15:13:26 -04:00
#[ test ]
fn test_parse_confirmations_2 ( ) -> anyhow ::Result < ( ) > {
struct Test {
text : & 'static str ,
}
let cases = [ Test {
text : include_str ! ( " fixtures/confirmations/need-auth.json " ) ,
} ] ;
for case in cases . iter ( ) {
let confirmations = serde_json ::from_str ::< ConfirmationListResponse > ( case . text ) ? ;
assert_eq! ( confirmations . conf . len ( ) , 0 ) ;
assert_eq! ( confirmations . needauth , Some ( true ) ) ;
}
Ok ( ( ) )
}
2023-06-27 10:20:27 -04:00
#[ test ]
fn test_generate_confirmation_hash_for_time ( ) {
assert_eq! (
generate_confirmation_hash_for_time ( 1617591917 , " conf " , " GQP46b73Ws7gr8GmZFR0sDuau5c= " ) ,
String ::from ( " NaL8EIMhfy/7vBounJ0CvpKbrPk= " )
) ;
}
2023-06-22 16:20:15 -04:00
}