steamguard/
confirmation.rs

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
25/// Provides an interface that wraps the Steam mobile confirmation API.
26///
27/// Only compatible with WebApiTransport.
28pub 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	/// Respond to a confirmation.
122	///
123	/// Host: https://steamcommunity.com
124	/// Steam Endpoint: `GET /mobileconf/ajaxop`
125	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	/// Respond to more than 1 confirmation.
189	///
190	/// Host: https://steamcommunity.com
191	/// Steam Endpoint: `GET /mobileconf/multiajaxop`
192	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		// despite being called query parameters, they will actually go in the body
216		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	/// Bulk accept confirmations.
261	///
262	/// Sends one request per confirmation.
263	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	/// Bulk deny confirmations.
272	///
273	/// Sends one request per confirmation.
274	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	/// Bulk accept confirmations.
283	///
284	/// Uses a different endpoint than `accept_confirmation()` to submit multiple confirmations in one request.
285	pub fn accept_confirmations_bulk(&self, confs: &[Confirmation]) -> Result<(), ConfirmerError> {
286		self.send_multi_confirmation_ajax(confs, ConfirmationAction::Accept)
287	}
288
289	/// Bulk deny confirmations.
290	///
291	/// Uses a different endpoint than `deny_confirmation()` to submit multiple confirmations in one request.
292	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	/// Steam Endpoint: `GET /mobileconf/details/:id`
305	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/// A mobile confirmation. There are multiple things that can be confirmed, like trade offers.
370#[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	/// Trade offer ID or market transaction ID
377	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	/// Human readable representation of this confirmation.
390	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")]
403/// Source: <https://github.com/SteamDatabase/SteamTracking/blob/6e7797e69b714c59f4b5784780b24753c17732ba/Structs/enums.steamd#L1607-L1616>
404/// There are also some additional undocumented types.
405pub enum ConfirmationType {
406	Test = 1,
407	/// Occurs when sending a trade offer or accepting a received trade offer, only when there is items on the user's side
408	Trade = 2,
409	/// Occurs when selling an item on the Steam community market
410	MarketSell = 3,
411	FeatureOptOut = 4,
412	/// Occurs when changing the phone number associated with the account
413	PhoneNumberChange = 5,
414	/// Occurs when removing a phone number
415	AccountRecovery = 6,
416	/// Occurs when a new web API key is created via <https://steamcommunity.com/dev/apikey>
417	ApiKeyCreation = 9,
418	/// Occurs when a user is invited to join a Steam Family, and they have accepted the invitation. This is not used for accepting the initial invitation, just to confirm the acceptance.
419	///
420	/// Triggered upon accepting invitation here: <https://store.steampowered.com/account/familymanagement>
421	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}