1use std::borrow::Cow;
2
3use base64::Engine;
4use hmac::{Hmac, Mac};
5use log::*;
6use reqwest::{
7 cookie::CookieStore,
8 header::{CONTENT_TYPE, COOKIE, USER_AGENT},
9 Url,
10};
11use secrecy::ExposeSecret;
12use serde::Deserialize;
13use sha1::Sha1;
14
15use crate::{
16 steamapi::{self},
17 transport::Transport,
18 SteamGuardAccount,
19};
20
21lazy_static! {
22 static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::<Url>().unwrap();
23}
24
25pub struct Confirmer<'a, T> {
29 account: &'a SteamGuardAccount,
30 transport: T,
31}
32
33impl<'a, T> Confirmer<'a, T>
34where
35 T: Transport + Clone,
36{
37 pub fn new(transport: T, account: &'a SteamGuardAccount) -> Self {
38 Self { account, transport }
39 }
40
41 fn get_confirmation_query_params<'q>(
42 &'q self,
43 tag: &'q str,
44 time: u64,
45 ) -> Vec<(&'static str, Cow<'q, str>)> {
46 [
47 ("p", self.account.device_id.as_str().into()),
48 ("a", self.account.steam_id.to_string().into()),
49 (
50 "k",
51 generate_confirmation_hash_for_time(
52 time,
53 tag,
54 self.account.identity_secret.expose_secret(),
55 )
56 .into(),
57 ),
58 ("t", time.to_string().into()),
59 ("m", "react".into()),
60 ("tag", tag.into()),
61 ]
62 .into()
63 }
64
65 fn build_cookie_jar(&self) -> reqwest::cookie::Jar {
66 let cookies = reqwest::cookie::Jar::default();
67 let tokens = self.account.tokens.as_ref().unwrap();
68 cookies.add_cookie_str("dob=", &STEAM_COOKIE_URL);
69 cookies.add_cookie_str(
70 format!("steamid={}", self.account.steam_id).as_str(),
71 &STEAM_COOKIE_URL,
72 );
73 cookies.add_cookie_str(
74 format!(
75 "steamLoginSecure={}||{}",
76 self.account.steam_id,
77 tokens.access_token().expose_secret()
78 )
79 .as_str(),
80 &STEAM_COOKIE_URL,
81 );
82 cookies
83 }
84
85 pub fn get_confirmations(&self) -> Result<Vec<Confirmation>, ConfirmerError> {
86 let cookies = self.build_cookie_jar();
87 let client = self.transport.innner_http_client()?;
88
89 let time = steamapi::get_server_time(self.transport.clone())?.server_time();
90 let resp = client
91 .get(
92 "https://steamcommunity.com/mobileconf/getlist"
93 .parse::<Url>()
94 .unwrap(),
95 )
96 .header(USER_AGENT, "steamguard-cli")
97 .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap())
98 .query(&self.get_confirmation_query_params("conf", time))
99 .send()?;
100
101 trace!("{:?}", resp);
102 let text = resp.text().unwrap();
103 debug!("Confirmations response: {}", text);
104
105 let mut deser = serde_json::Deserializer::from_str(text.as_str());
106 let body: ConfirmationListResponse = serde_path_to_error::deserialize(&mut deser)?;
107
108 if body.needauth.unwrap_or(false) {
109 return Err(ConfirmerError::InvalidTokens);
110 }
111 if !body.success {
112 if let Some(msg) = body.message {
113 return Err(ConfirmerError::RemoteFailureWithMessage(msg));
114 } else {
115 return Err(ConfirmerError::RemoteFailure);
116 }
117 }
118 Ok(body.conf)
119 }
120
121 fn send_confirmation_ajax(
126 &self,
127 conf: &Confirmation,
128 action: ConfirmationAction,
129 ) -> Result<(), ConfirmerError> {
130 debug!("responding to a single confirmation: send_confirmation_ajax()");
131 let operation = action.to_operation();
132
133 let cookies = self.build_cookie_jar();
134 let client = self.transport.innner_http_client()?;
135
136 let time = steamapi::get_server_time(self.transport.clone())?.server_time();
137 let mut query_params = self.get_confirmation_query_params("conf", time);
138 query_params.push(("op", operation.into()));
139 query_params.push(("cid", Cow::Borrowed(&conf.id)));
140 query_params.push(("ck", Cow::Borrowed(&conf.nonce)));
141
142 let resp = client
143 .get(
144 "https://steamcommunity.com/mobileconf/ajaxop"
145 .parse::<Url>()
146 .unwrap(),
147 )
148 .header(USER_AGENT, "steamguard-cli")
149 .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap())
150 .header("Origin", "https://steamcommunity.com")
151 .query(&query_params)
152 .send()?;
153
154 trace!("send_confirmation_ajax() response: {:?}", &resp);
155 debug!(
156 "send_confirmation_ajax() response status code: {}",
157 &resp.status()
158 );
159
160 let raw = resp.text()?;
161 debug!("send_confirmation_ajax() response body: {:?}", &raw);
162
163 let mut deser = serde_json::Deserializer::from_str(raw.as_str());
164 let body: SendConfirmationResponse = serde_path_to_error::deserialize(&mut deser)?;
165
166 if body.needsauth.unwrap_or(false) {
167 return Err(ConfirmerError::InvalidTokens);
168 }
169 if !body.success {
170 if let Some(msg) = body.message {
171 return Err(ConfirmerError::RemoteFailureWithMessage(msg));
172 } else {
173 return Err(ConfirmerError::RemoteFailure);
174 }
175 }
176
177 Ok(())
178 }
179
180 pub fn accept_confirmation(&self, conf: &Confirmation) -> Result<(), ConfirmerError> {
181 self.send_confirmation_ajax(conf, ConfirmationAction::Accept)
182 }
183
184 pub fn deny_confirmation(&self, conf: &Confirmation) -> Result<(), ConfirmerError> {
185 self.send_confirmation_ajax(conf, ConfirmationAction::Deny)
186 }
187
188 fn send_multi_confirmation_ajax(
193 &self,
194 confs: &[Confirmation],
195 action: ConfirmationAction,
196 ) -> Result<(), ConfirmerError> {
197 debug!("responding to bulk confirmations: send_multi_confirmation_ajax()");
198 if confs.is_empty() {
199 debug!("confs is empty, nothing to do.");
200 return Ok(());
201 }
202 let operation = action.to_operation();
203
204 let cookies = self.build_cookie_jar();
205 let client = self.transport.innner_http_client()?;
206
207 let time = steamapi::get_server_time(self.transport.clone())?.server_time();
208 let mut query_params = self.get_confirmation_query_params("conf", time);
209 query_params.push(("op", operation.into()));
210 for conf in confs.iter() {
211 query_params.push(("cid[]", Cow::Borrowed(&conf.id)));
212 query_params.push(("ck[]", Cow::Borrowed(&conf.nonce)));
213 }
214 let query_params = self.build_multi_conf_query_string(&query_params);
215 debug!("query_params: {}", &query_params);
217
218 let resp = client
219 .post(
220 "https://steamcommunity.com/mobileconf/multiajaxop"
221 .parse::<Url>()
222 .unwrap(),
223 )
224 .header(USER_AGENT, "steamguard-cli")
225 .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap())
226 .header(
227 CONTENT_TYPE,
228 "application/x-www-form-urlencoded; charset=UTF-8",
229 )
230 .header("Origin", "https://steamcommunity.com")
231 .body(query_params)
232 .send()?;
233
234 trace!("send_multi_confirmation_ajax() response: {:?}", &resp);
235 debug!(
236 "send_multi_confirmation_ajax() response status code: {}",
237 &resp.status()
238 );
239
240 let raw = resp.text()?;
241 debug!("send_multi_confirmation_ajax() response body: {:?}", &raw);
242
243 let mut deser = serde_json::Deserializer::from_str(raw.as_str());
244 let body: SendConfirmationResponse = serde_path_to_error::deserialize(&mut deser)?;
245
246 if body.needsauth.unwrap_or(false) {
247 return Err(ConfirmerError::InvalidTokens);
248 }
249 if !body.success {
250 if let Some(msg) = body.message {
251 return Err(ConfirmerError::RemoteFailureWithMessage(msg));
252 } else {
253 return Err(ConfirmerError::RemoteFailure);
254 }
255 }
256
257 Ok(())
258 }
259
260 pub fn accept_confirmations(&self, confs: &[Confirmation]) -> Result<(), ConfirmerError> {
264 for conf in confs {
265 self.accept_confirmation(conf)?;
266 }
267
268 Ok(())
269 }
270
271 pub fn deny_confirmations(&self, confs: &[Confirmation]) -> Result<(), ConfirmerError> {
275 for conf in confs {
276 self.deny_confirmation(conf)?;
277 }
278
279 Ok(())
280 }
281
282 pub fn accept_confirmations_bulk(&self, confs: &[Confirmation]) -> Result<(), ConfirmerError> {
286 self.send_multi_confirmation_ajax(confs, ConfirmationAction::Accept)
287 }
288
289 pub fn deny_confirmations_bulk(&self, confs: &[Confirmation]) -> Result<(), ConfirmerError> {
293 self.send_multi_confirmation_ajax(confs, ConfirmationAction::Deny)
294 }
295
296 fn build_multi_conf_query_string(&self, params: &[(&str, Cow<str>)]) -> String {
297 params
298 .iter()
299 .map(|(k, v)| format!("{}={}", k, v))
300 .collect::<Vec<_>>()
301 .join("&")
302 }
303
304 pub fn get_confirmation_details(&self, conf: &Confirmation) -> anyhow::Result<String> {
306 #[derive(Debug, Clone, Deserialize)]
307 struct ConfirmationDetailsResponse {
308 pub success: bool,
309 pub html: String,
310 }
311
312 let cookies = self.build_cookie_jar();
313 let client = self.transport.innner_http_client()?;
314
315 let time = steamapi::get_server_time(self.transport.clone())?.server_time();
316 let query_params = self.get_confirmation_query_params("details", time);
317
318 let resp = client
319 .get(
320 format!("https://steamcommunity.com/mobileconf/details/{}", conf.id)
321 .parse::<Url>()
322 .unwrap(),
323 )
324 .header(USER_AGENT, "steamguard-cli")
325 .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap())
326 .query(&query_params)
327 .send()?;
328
329 let text = resp.text()?;
330 let mut deser = serde_json::Deserializer::from_str(text.as_str());
331 let body: ConfirmationDetailsResponse = serde_path_to_error::deserialize(&mut deser)?;
332
333 ensure!(body.success);
334 Ok(body.html)
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum ConfirmationAction {
340 Accept,
341 Deny,
342}
343
344impl ConfirmationAction {
345 fn to_operation(self) -> &'static str {
346 match self {
347 ConfirmationAction::Accept => "allow",
348 ConfirmationAction::Deny => "cancel",
349 }
350 }
351}
352
353#[derive(Debug, thiserror::Error)]
354pub enum ConfirmerError {
355 #[error("Invalid tokens, login or token refresh required.")]
356 InvalidTokens,
357 #[error("Network failure: {0}")]
358 NetworkFailure(#[from] reqwest::Error),
359 #[error("Failed to deserialize response: {0}")]
360 DeserializeError(#[from] serde_path_to_error::Error<serde_json::Error>),
361 #[error("Remote failure: Valve's server responded with a failure and did not elaborate any further. This is likely not a steamguard-cli bug, Steam's confirmation API is just unreliable. Wait a bit and try again.")]
362 RemoteFailure,
363 #[error("Remote failure: Valve's server responded with a failure and said: {0}")]
364 RemoteFailureWithMessage(String),
365 #[error("Unknown error: {0}")]
366 Unknown(#[from] anyhow::Error),
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
371pub struct Confirmation {
372 #[serde(rename = "type")]
373 pub conf_type: ConfirmationType,
374 pub type_name: String,
375 pub id: String,
376 pub creator_id: String,
378 pub nonce: String,
379 pub creation_time: u64,
380 pub cancel: String,
381 pub accept: String,
382 pub icon: Option<String>,
383 pub multi: bool,
384 pub headline: String,
385 pub summary: Vec<String>,
386}
387
388impl Confirmation {
389 pub fn description(&self) -> String {
391 format!(
392 "{:?} - {} - {}",
393 self.conf_type,
394 self.headline,
395 self.summary.join(", ")
396 )
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, num_enum::FromPrimitive)]
401#[repr(u32)]
402#[serde(from = "u32")]
403pub enum ConfirmationType {
406 Test = 1,
407 Trade = 2,
409 MarketSell = 3,
411 FeatureOptOut = 4,
412 PhoneNumberChange = 5,
414 AccountRecovery = 6,
416 ApiKeyCreation = 9,
418 JoinSteamFamily = 11,
422 #[num_enum(catch_all)]
423 Unknown(u32),
424}
425
426#[derive(Debug, Deserialize)]
427pub struct ConfirmationListResponse {
428 pub success: bool,
429 #[serde(default)]
430 pub needauth: Option<bool>,
431 #[serde(default)]
432 pub conf: Vec<Confirmation>,
433 #[serde(default)]
434 pub message: Option<String>,
435}
436
437#[derive(Debug, Clone, Deserialize)]
438pub struct SendConfirmationResponse {
439 pub success: bool,
440 #[serde(default)]
441 pub needsauth: Option<bool>,
442 #[serde(default)]
443 pub message: Option<String>,
444}
445
446fn build_time_bytes(time: u64) -> [u8; 8] {
447 time.to_be_bytes()
448}
449
450fn generate_confirmation_hash_for_time(
451 time: u64,
452 tag: &str,
453 identity_secret: impl AsRef<[u8]>,
454) -> String {
455 let decode: &[u8] = &base64::engine::general_purpose::STANDARD
456 .decode(identity_secret)
457 .unwrap();
458 let mut mac = Hmac::<Sha1>::new_from_slice(decode).unwrap();
459 mac.update(&build_time_bytes(time));
460 mac.update(tag.as_bytes());
461 let result = mac.finalize();
462 let hash = result.into_bytes();
463 base64::engine::general_purpose::STANDARD.encode(hash)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_parse_confirmations() -> anyhow::Result<()> {
472 struct Test {
473 text: &'static str,
474 confirmation_type: ConfirmationType,
475 }
476 let cases = [
477 Test {
478 text: include_str!("fixtures/confirmations/email-change.json"),
479 confirmation_type: ConfirmationType::AccountRecovery,
480 },
481 Test {
482 text: include_str!("fixtures/confirmations/phone-number-change.json"),
483 confirmation_type: ConfirmationType::PhoneNumberChange,
484 },
485 ];
486 for case in cases.iter() {
487 let confirmations = serde_json::from_str::<ConfirmationListResponse>(case.text)?;
488
489 assert_eq!(confirmations.conf.len(), 1);
490
491 let confirmation = &confirmations.conf[0];
492
493 assert_eq!(confirmation.conf_type, case.confirmation_type);
494 }
495
496 Ok(())
497 }
498
499 #[test]
500 fn test_parse_confirmations_2() -> anyhow::Result<()> {
501 struct Test {
502 text: &'static str,
503 }
504 let cases = [Test {
505 text: include_str!("fixtures/confirmations/need-auth.json"),
506 }];
507 for case in cases.iter() {
508 let confirmations = serde_json::from_str::<ConfirmationListResponse>(case.text)?;
509
510 assert_eq!(confirmations.conf.len(), 0);
511 assert_eq!(confirmations.needauth, Some(true));
512 }
513
514 Ok(())
515 }
516
517 #[test]
518 fn test_generate_confirmation_hash_for_time() {
519 assert_eq!(
520 generate_confirmation_hash_for_time(1617591917, "conf", "GQP46b73Ws7gr8GmZFR0sDuau5c="),
521 String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk=")
522 );
523 }
524}