another_steam_totp/
lib.rs

1//! Provides functionality relating to Steam TOTP. Based on
2//! <https://github.com/DoctorMcKay/node-steam-totp>. Designed to be easy-to-use while providing
3//! all necessary features.
4//! 
5//! # Usage
6//! ```
7//! use another_steam_totp::generate_auth_code;
8//! 
9//! let shared_secret = "000000000000000000000000000=";
10//! let time_offset = None;
11//! // Generates the 5-character time-based one-time password using
12//! // your shared_secret.
13//! let code = generate_auth_code(shared_secret, time_offset).unwrap();
14//! 
15//! assert_eq!(code.len(), 5);
16//! ```
17
18#![cfg_attr(docsrs, feature(doc_cfg))]
19
20mod error;
21mod tag;
22mod decode;
23
24#[cfg(any(feature = "reqwest", feature = "ureq"))]
25mod http;
26
27pub use error::Error;
28pub use tag::Tag;
29
30#[cfg(feature = "reqwest")]
31pub use http::get_steam_server_time_offset;
32
33#[cfg(feature = "ureq")]
34pub use http::get_steam_server_time_offset_sync;
35
36use decode::decode_secret;
37use std::time::{SystemTime, UNIX_EPOCH};
38use std::fmt::Write;
39use hmac::{Hmac, Mac};
40use sha1::{Sha1, Digest};
41use base64::Engine;
42
43const CHARS: &[char] = &[
44    '2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C', 'D', 'F', 'G',
45    'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'V', 'W', 'X', 'Y',
46];
47
48type HmacSha1 = Hmac<Sha1>;
49
50/// Generates the 5-character authentication code to login to Steam using your base64-encoded or
51/// hex-encoded `shared_secret`.
52/// 
53/// The `time_offset` is the number of seconds in which your system is **behind** Steam's servers.
54/// Defaults to `0` if `None` is provided. Refer to [`get_steam_server_time_offset`] for more
55/// details.
56/// 
57/// # Examples
58/// ```
59/// use another_steam_totp::generate_auth_code;
60/// 
61/// let shared_secret = "000000000000000000000000000=";
62/// let code = generate_auth_code(shared_secret, None).unwrap();
63/// ```
64pub fn generate_auth_code<T: AsRef<[u8]>>(
65    shared_secret: T,
66    time_offset: Option<i64>,
67) -> Result<String, Error> {
68    let timestamp = get_offset_timestamp(time_offset)?;
69    
70    generate_auth_code_for_time(shared_secret, timestamp)
71}
72
73/// Generates a confirmation key for responding to mobile confirmations using your base64-encoded
74/// or hex-encoded `identity_secret`.
75/// 
76/// The `time_offset` is the number of seconds in which your system is **behind** Steam's servers.
77/// Defaults to `0` if `None` is provided. Refer to [`get_steam_server_time_offset`] for more
78/// details.
79/// 
80/// Returns both the confirmation key and the timestamp used to generate the confirmation key,
81/// these are required parameters when sending the request for
82/// `https://steamcommunity.com/mobileconf/mobileconf/ajaxop`.
83/// 
84/// # Examples
85/// ```
86/// use another_steam_totp::{generate_confirmation_key, Tag};
87/// 
88/// let identity_secret = "000000000000000000000000000=";
89/// let (code, timestamp) = generate_confirmation_key(identity_secret, Tag::Allow, None).unwrap();
90/// ```
91pub fn generate_confirmation_key<T: AsRef<[u8]>>(
92    identity_secret: T,
93    tag: Tag,
94    time_offset: Option<i64>,
95) -> Result<(String, u64), Error> {
96    let timestamp = get_offset_timestamp(time_offset)?;
97    let confirmation_key = generate_confirmation_key_for_time(identity_secret, tag, timestamp)?;
98    
99    Ok((confirmation_key, timestamp))
100}
101
102/// Gets the device ID for a given 64-bit `steamid`.
103/// 
104/// # Examples
105/// ```
106/// use another_steam_totp::get_device_id;
107/// 
108/// let steamid: u64 = 76561197960287930;
109/// let device_id = get_device_id(steamid);
110/// 
111/// assert_eq!(device_id, "android:6d3f10d9-6369-a1ae-97a0-94df28b95192");
112/// ```
113pub fn get_device_id(steamid: u64) -> String {
114    generate_device_id(steamid, None)
115}
116
117/// Gets the device ID for a given 64-bit `steamid` with a salt.
118/// 
119/// # Examples
120/// ```
121/// use another_steam_totp::get_device_id_with_salt;
122/// 
123/// let steamid: u64 = 76561197960287930;
124/// let device_id = get_device_id_with_salt(steamid, "my_salt");
125/// 
126/// assert_eq!(device_id, "android:bf5ccd6c-3baf-53a8-b21d-7c2d8bb1e9bb");
127/// ```
128pub fn get_device_id_with_salt(steamid: u64, salt: &str) -> String {
129    generate_device_id(steamid, Some(salt))
130}
131
132/// Gets the device ID for a given 64-bit `steamid` and an optional salt.
133fn generate_device_id(steamid: u64, salt: Option<&str>) -> String {
134    let mut hasher = Sha1::new();
135    
136    if let Some(salt) = salt {
137        hasher.update(format!("{steamid}{salt}"));
138    } else {
139        hasher.update(steamid.to_string());
140    }
141    
142    let result = hasher.finalize();
143    let hash = result
144        .iter()
145        .fold(String::new(), |mut output, b| {
146            let _ = write!(output, "{b:02x}");
147            output
148        });
149    // sourced from https://github.com/saskenuba/SteamHelper-rs/blob/37d890c1491677415562d6e7440fde64bbeef12e/crates/steam-totp/src/lib.rs#L124
150    let (p1, rest) = hash.split_at(8);
151    let (p2, rest) = rest.split_at(4);
152    let (p3, rest) = rest.split_at(4);
153    let (p4, rest) = rest.split_at(4);
154    let (p5, _) = rest.split_at(12);
155    
156    format!("android:{p1}-{p2}-{p3}-{p4}-{p5}")
157}
158
159/// Gets your computer's current system time as a Unix timestamp.
160fn current_timestamp() -> Result<u64, Error> {
161    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
162}
163
164/// Generates an auth code for the given time.
165fn generate_auth_code_for_time<T: AsRef<[u8]>>(
166    shared_secret: T,
167    timestamp: u64,
168) -> Result<String, Error> {
169    let mut full_code = {
170        let bytes = (timestamp / 30).to_be_bytes();
171        let hmac = get_hmac_msg(shared_secret, &bytes)?;
172        let result = hmac.finalize().into_bytes();
173        let slice_start = result[19] & 0x0F;
174        let slice_end = slice_start + 4;
175        let slice: &[u8] = &result[slice_start as usize..slice_end as usize];
176        let full_code_slice: [u8; 4] = slice.try_into()
177            // This probably should never fail.
178            .map_err(|_e| std::io::Error::new(
179                std::io::ErrorKind::Other,
180                "Failed to convert slice to array.",
181            ))?;
182        let full_code_bytes = u32::from_be_bytes(full_code_slice);
183        
184        full_code_bytes & 0x7FFFFFFF
185    };
186    let chars_len = CHARS.len() as u32;
187    let code = (0..5)
188        .map(|_i| {
189            let char_code = CHARS[(full_code % chars_len) as usize];
190            
191            full_code /= chars_len;
192            char_code
193        })
194        .collect::<String>();
195    
196    Ok(code)
197}
198
199/// Generates a confirmation key for the given time.
200fn generate_confirmation_key_for_time<T: AsRef<[u8]>>(
201    identity_secret: T,
202    tag: Tag,
203    timestamp: u64,
204) -> Result<String, Error> {
205    let timestamp_bytes = timestamp.to_be_bytes();
206    let tag_string = tag.to_string();
207    let tag_bytes = tag_string.as_bytes();
208    let array = [&timestamp_bytes, tag_bytes].concat();
209    let hmac = get_hmac_msg(identity_secret, &array)?;
210    let code_bytes = hmac.finalize().into_bytes();
211    
212    Ok(base64::engine::general_purpose::STANDARD.encode(code_bytes))
213}
214
215/// Generates an hmac message.
216fn get_hmac_msg<T: AsRef<[u8]>>(
217    secret: T,
218    bytes: &[u8],
219) -> Result<HmacSha1, Error> {
220    let decoded = decode_secret(secret)?;
221    let mut mac = HmacSha1::new_from_slice(&decoded[..])
222        .map_err(|_e| Error::EmptySecret)?;
223    
224    mac.update(bytes);
225    Ok(mac)
226}
227
228/// Subtracts a signed integer from an unsigned integer, saturating at bounds.
229fn subtract_unsigned_signed(u: u64, i: i64) -> u64 {
230    if i >= 0 {
231        u.saturating_sub(i as u64)
232    } else {
233        u.saturating_add((-i) as u64)
234    }
235}
236
237/// Gets the current timestamp adjusted by the provided time offset.
238fn get_offset_timestamp(time_offset: Option<i64>) -> Result<u64, Error> {
239    let current_timestamp = current_timestamp()?;
240    let time_offset = time_offset.unwrap_or(0);
241    let timestamp = subtract_unsigned_signed(
242        current_timestamp,
243        time_offset,
244    );
245    
246    Ok(timestamp)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    
253    #[test]
254    fn generates_confirmation_key_for_time() {
255        let identity_secret: &'static str = "000000000000000000000000000=";
256        let timestamp = 1634603498;
257        let hash = generate_confirmation_key_for_time(
258            identity_secret,
259            Tag::Allow,
260            timestamp,
261        ).unwrap();
262        
263        assert_eq!(hash, "9/OyNC3rk7VNsMFklzayOuznImU=");
264    }
265    
266    #[test]
267    fn generating_a_code_works() {
268        let shared_secret = "000000000000000000000000000=";
269        let timestamp = 1634603498;
270        let code = generate_auth_code_for_time(shared_secret, timestamp).unwrap();
271        
272        assert_eq!(code, "2C5H2");
273    }
274    
275    #[test]
276    fn generating_a_code_from_hex_works() {
277        // This is the same as `000000000000000000000000000=` (base64)
278        let shared_secret = "D34D34D34D34D34D34D34D34D34D34D34D34D34D";
279        let timestamp = 1634603498;
280        let code = generate_auth_code_for_time(shared_secret, timestamp).unwrap();
281        
282        assert_eq!(code, "2C5H2");
283    }
284    
285    #[test]
286    fn gets_device_id() {
287        let device_id = get_device_id(76561197960287930);
288        
289        assert_eq!(device_id, "android:6d3f10d9-6369-a1ae-97a0-94df28b95192");
290    }
291}