Skip to main content

steam_totp/
lib.rs

1//! Steam TOTP and confirmation key generation.
2//!
3//! This crate provides a pure Rust implementation of the `node-steam-totp`
4//! library, offering utilities for:
5//! - Generating Steam-compatible TOTP authentication codes
6//! - Creating confirmation keys for mobile trade confirmations
7//! - Generating device IDs from SteamIDs
8//! - Querying Steam server time offsets
9//!
10//! # Example
11//!
12//! ```rust
13//! use steam_totp::{generate_auth_code, Secret};
14//!
15//! // Generate a TOTP code from a base64 shared secret
16//! let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
17//! let code = generate_auth_code(&secret, 0).expect("totp error");
18//! println!("Your Steam Guard code: {}", code);
19//! ```
20
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
24use hmac::{Hmac, Mac};
25use sha1::Sha1;
26pub use steamid::SteamID;
27
28/// Steam's character set for TOTP codes (different from standard TOTP).
29const STEAM_CHARS: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
30
31/// Maximum tag length for confirmation keys (as per node-steam-totp).
32const MAX_TAG_LENGTH: usize = 32;
33
34/// Errors that can occur during TOTP operations.
35#[derive(Debug, thiserror::Error)]
36pub enum TotpError {
37    /// Base64 decoding failed.
38    #[error("Invalid base64 encoding: {0}")]
39    Base64Error(#[from] base64::DecodeError),
40
41    /// Hex decoding failed.
42    #[error("Invalid hex encoding: {0}")]
43    HexError(#[from] hex::FromHexError),
44
45    /// HMAC operation failed.
46    #[error("HMAC error: {0}")]
47    HmacError(String),
48
49    /// HTTP request failed.
50    #[error("HTTP error: {0}")]
51    HttpError(#[from] reqwest::Error),
52
53    /// Invalid response from Steam servers.
54    #[error("Malformed response: {0}")]
55    MalformedResponse(String),
56
57    /// System time error.
58    #[error("System time error: {0}")]
59    TimeError(String),
60}
61
62/// Result type for TOTP operations.
63pub type Result<T> = std::result::Result<T, TotpError>;
64
65/// A secret key that can be provided in multiple formats.
66///
67/// Supports:
68/// - Raw bytes (`&[u8]`)
69/// - Hex-encoded string (40 characters)
70/// - Base64-encoded string
71#[derive(Debug, Clone)]
72pub struct Secret(Vec<u8>);
73
74impl Secret {
75    /// Create a secret from raw bytes.
76    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Self {
77        Self(bytes.as_ref().to_vec())
78    }
79
80    /// Create a secret from a base64-encoded string.
81    pub fn from_base64(s: &str) -> Result<Self> {
82        let bytes = BASE64.decode(s)?;
83        Ok(Self(bytes))
84    }
85
86    /// Create a secret from a hex-encoded string.
87    pub fn from_hex(s: &str) -> Result<Self> {
88        let bytes = hex::decode(s)?;
89        Ok(Self(bytes))
90    }
91
92    /// Automatically detect the format and decode the secret.
93    ///
94    /// Detection rules (matching node-steam-totp behavior):
95    /// - If the string is exactly 40 hex characters, decode as hex
96    /// - Otherwise, decode as base64
97    pub fn from_string(s: &str) -> Result<Self> {
98        // Check if it's a 40-character hex string
99        if s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) {
100            Self::from_hex(s)
101        } else {
102            Self::from_base64(s)
103        }
104    }
105
106    /// Get the raw bytes of the secret.
107    pub fn as_bytes(&self) -> &[u8] {
108        &self.0
109    }
110}
111
112impl AsRef<[u8]> for Secret {
113    fn as_ref(&self) -> &[u8] {
114        &self.0
115    }
116}
117
118impl From<Vec<u8>> for Secret {
119    fn from(bytes: Vec<u8>) -> Self {
120        Self(bytes)
121    }
122}
123
124impl From<&[u8]> for Secret {
125    fn from(bytes: &[u8]) -> Self {
126        Self(bytes.to_vec())
127    }
128}
129
130// ============================================================================
131// Time Functions
132// ============================================================================
133
134/// Returns the current local Unix time with an optional offset.
135///
136/// # Arguments
137///
138/// * `time_offset` - Seconds to add to the current time (default 0)
139///
140/// # Returns
141///
142/// Current Unix timestamp plus the offset.
143///
144/// # Example
145///
146/// ```rust
147/// use steam_totp::time;
148///
149/// let current = time(0);
150/// let adjusted = time(30); // 30 seconds in the future
151/// ```
152pub fn time(time_offset: i64) -> i64 {
153    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
154    now + time_offset
155}
156
157// ============================================================================
158// TOTP Generation
159// ============================================================================
160
161/// Generate a Steam-style TOTP authentication code.
162///
163/// # Arguments
164///
165/// * `secret` - Your TOTP shared_secret
166/// * `time_offset` - Seconds offset from current time (default 0)
167///
168/// # Returns
169///
170/// A 5-character alphanumeric Steam Guard code.
171///
172/// # Example
173///
174/// ```rust
175/// use steam_totp::{generate_auth_code, Secret};
176///
177/// let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
178/// let code = generate_auth_code(&secret, 0).expect("totp error");
179/// assert_eq!(code.len(), 5);
180/// ```
181pub fn generate_auth_code(secret: &Secret, time_offset: i64) -> Result<String> {
182    let current_time = time(time_offset);
183    generate_auth_code_for_time(secret, current_time)
184}
185
186/// Generate a Steam-style TOTP code for a specific Unix timestamp.
187///
188/// This is useful for testing or when you need to generate codes for specific
189/// times.
190pub fn generate_auth_code_for_time(secret: &Secret, time: i64) -> Result<String> {
191    // Steam uses 30-second intervals
192    let time_step = (time / 30) as u64;
193
194    // Create 8-byte buffer: first 4 bytes are 0 (high bits), last 4 are the time
195    // step
196    let mut time_bytes = [0u8; 8];
197    time_bytes[4..8].copy_from_slice(&(time_step as u32).to_be_bytes());
198
199    // HMAC-SHA1
200    let mut mac = Hmac::<Sha1>::new_from_slice(secret.as_bytes()).map_err(|e| TotpError::HmacError(e.to_string()))?;
201    mac.update(&time_bytes);
202    let hmac = mac.finalize().into_bytes();
203
204    // Dynamic truncation
205    let start = (hmac[19] & 0x0f) as usize;
206    let fullcode = ((hmac[start] as u32 & 0x7f) << 24) | ((hmac[start + 1] as u32) << 16) | ((hmac[start + 2] as u32) << 8) | (hmac[start + 3] as u32);
207
208    // Generate 5-character code using Steam's alphabet
209    let mut code = String::with_capacity(5);
210    let mut remaining = fullcode;
211    for _ in 0..5 {
212        let idx = (remaining % STEAM_CHARS.len() as u32) as usize;
213        code.push(STEAM_CHARS[idx] as char);
214        remaining /= STEAM_CHARS.len() as u32;
215    }
216
217    Ok(code)
218}
219
220// ============================================================================
221// Confirmation Key Generation
222// ============================================================================
223
224/// Generate a base64 confirmation key for use with mobile trade confirmations.
225///
226/// The key can only be used once.
227///
228/// # Arguments
229///
230/// * `identity_secret` - The identity_secret from two-factor authentication
231/// * `time` - Unix timestamp for which you are generating this key
232/// * `tag` - Identifies what this request will be for:
233///   - `"conf"` - Load the confirmations page
234///   - `"details"` - Load details about a trade
235///   - `"allow"` - Confirm a trade
236///   - `"cancel"` - Cancel a trade
237///
238/// # Returns
239///
240/// Base64-encoded confirmation key.
241///
242/// # Example
243///
244/// ```rust
245/// use steam_totp::{generate_confirmation_key, time, Secret};
246///
247/// let identity = Secret::from_base64("dGVzdHNlY3JldA==").expect("totp error");
248/// let key = generate_confirmation_key(&identity, time(0), "conf").expect("totp error");
249/// ```
250pub fn generate_confirmation_key(identity_secret: &Secret, time: i64, tag: &str) -> Result<String> {
251    // Calculate data length: 8 bytes for time + tag bytes (max 32)
252    let tag_bytes = tag.as_bytes();
253    let tag_len = tag_bytes.len().min(MAX_TAG_LENGTH);
254    let data_len = 8 + tag_len;
255
256    // Build the data buffer
257    let mut buffer = Vec::with_capacity(data_len);
258
259    // Write time as 64-bit big-endian
260    buffer.extend_from_slice(&(time as u64).to_be_bytes());
261
262    // Write tag (truncated to 32 bytes if needed)
263    buffer.extend_from_slice(&tag_bytes[..tag_len]);
264
265    // HMAC-SHA1
266    let mut mac = Hmac::<Sha1>::new_from_slice(identity_secret.as_bytes()).map_err(|e| TotpError::HmacError(e.to_string()))?;
267    mac.update(&buffer);
268    let result = mac.finalize().into_bytes();
269
270    Ok(BASE64.encode(result))
271}
272
273// ============================================================================
274// Time Offset
275// ============================================================================
276
277/// Response from querying Steam's time server.
278#[derive(Debug, Clone, Copy)]
279pub struct TimeOffsetResponse {
280    /// Seconds we are behind Steam. Add this to local time to get Steam time.
281    pub offset: i64,
282    /// Round-trip latency in milliseconds.
283    pub latency_ms: u64,
284}
285
286/// Query the Steam servers for the time offset.
287///
288/// The offset is how many seconds we are **behind** Steam. Add this number
289/// to local time to get Steam time.
290///
291/// # Returns
292///
293/// A [`TimeOffsetResponse`] containing the offset and request latency.
294///
295/// # Example
296///
297/// ```rust,ignore
298/// use steam_totp::get_time_offset;
299///
300/// let response = get_time_offset().await?;
301/// println!("Time offset: {} seconds", response.offset);
302/// println!("Latency: {} ms", response.latency_ms);
303/// ```
304pub async fn get_time_offset() -> Result<TimeOffsetResponse> {
305    let start = std::time::Instant::now();
306
307    let client = reqwest::Client::new();
308    let response: serde_json::Value = client.post("https://api.steampowered.com/ITwoFactorService/QueryTime/v1/").header("Content-Length", "0").send().await?.json().await?;
309
310    let latency_ms = start.elapsed().as_millis() as u64;
311
312    let server_time = response.get("response").and_then(|r| r.get("server_time")).and_then(|t| t.as_str().or_else(|| t.as_i64().map(|_| "").and(None))).and_then(|s| s.parse::<i64>().ok()).or_else(|| response.get("response").and_then(|r| r.get("server_time")).and_then(|t| t.as_i64())).ok_or_else(|| TotpError::MalformedResponse("Missing or invalid server_time".into()))?;
313
314    let local_time = time(0);
315    let offset = server_time - local_time;
316
317    Ok(TimeOffsetResponse { offset, latency_ms })
318}
319
320// ============================================================================
321// Device ID Generation
322// ============================================================================
323
324/// Generate a standardized device ID based on a SteamID.
325///
326/// This matches the algorithm used by Steam's official mobile app.
327///
328/// # Arguments
329///
330/// * `steam_id` - Your SteamID
331/// * `salt` - Optional salt to append (equivalent to `STEAM_TOTP_SALT` env var)
332///
333/// # Returns
334///
335/// A device ID in the format `android:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`
336///
337/// # Example
338///
339/// ```rust
340/// use steam_totp::generate_device_id;
341/// use steamid::SteamID;
342///
343/// let steam_id = SteamID::from(76561198012345678u64);
344/// let device_id = generate_device_id(steam_id, None);
345/// assert!(device_id.starts_with("android:"));
346/// ```
347pub fn generate_device_id(steam_id: impl Into<SteamID>, salt: Option<&str>) -> String {
348    use sha1::Digest;
349
350    let steam_id: SteamID = steam_id.into();
351    let mut input = steam_id.steam_id64().to_string();
352
353    // Append salt if provided
354    if let Some(s) = salt {
355        input.push_str(s);
356    }
357
358    // SHA-1 hash
359    let hash = Sha1::digest(input.as_bytes());
360    let hex = hex::encode(hash);
361
362    // Format as UUID: android:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
363    format!("android:{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32])
364}
365
366/// Generate a device ID using the `STEAM_TOTP_SALT` environment variable if
367/// set.
368///
369/// This is a convenience function that reads the salt from the environment.
370pub fn generate_device_id_with_env_salt(steam_id: impl Into<SteamID>) -> String {
371    let salt = std::env::var("STEAM_TOTP_SALT").ok();
372    generate_device_id(steam_id, salt.as_deref())
373}
374
375// ============================================================================
376// Tests
377// ============================================================================
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_secret_from_base64() {
385        let secret = Secret::from_base64("SGVsbG9Xb3JsZA==").expect("totp error");
386        assert_eq!(secret.as_bytes(), b"HelloWorld");
387    }
388
389    #[test]
390    fn test_secret_from_hex() {
391        let secret = Secret::from_hex("48656c6c6f576f726c64").expect("totp error");
392        assert_eq!(secret.as_bytes(), b"HelloWorld");
393    }
394
395    #[test]
396    fn test_secret_auto_detect_hex() {
397        // 40-char hex string should be detected as hex
398        let hex = "0123456789abcdef0123456789abcdef01234567";
399        let secret = Secret::from_string(hex).expect("totp error");
400        assert_eq!(secret.as_bytes().len(), 20);
401    }
402
403    #[test]
404    fn test_secret_auto_detect_base64() {
405        // Non-40-char string or non-hex should be detected as base64
406        let secret = Secret::from_string("SGVsbG9Xb3JsZA==").expect("totp error");
407        assert_eq!(secret.as_bytes(), b"HelloWorld");
408    }
409
410    #[test]
411    fn test_generate_auth_code_format() {
412        let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
413        let code = generate_auth_code_for_time(&secret, 1609459200).expect("totp error");
414
415        // Code should be 5 characters
416        assert_eq!(code.len(), 5);
417
418        // All characters should be from Steam's alphabet
419        for c in code.chars() {
420            assert!("23456789BCDFGHJKMNPQRTVWXY".contains(c), "Invalid character in code: {c}");
421        }
422    }
423
424    #[test]
425    fn test_generate_auth_code_consistency() {
426        // Same secret and time should produce the same code
427        let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
428        let code1 = generate_auth_code_for_time(&secret, 1609459200).expect("totp error");
429        let code2 = generate_auth_code_for_time(&secret, 1609459200).expect("totp error");
430        assert_eq!(code1, code2);
431    }
432
433    #[test]
434    fn test_generate_auth_code_different_times() {
435        // Different time steps should produce different codes
436        let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
437        let code1 = generate_auth_code_for_time(&secret, 1609459200).expect("totp error"); // Time step 53648640
438        let code2 = generate_auth_code_for_time(&secret, 1609459230).expect("totp error"); // Time step 53648641
439        assert_ne!(code1, code2);
440    }
441
442    #[test]
443    fn test_generate_confirmation_key() {
444        let secret = Secret::from_base64("dGVzdHNlY3JldA==").expect("totp error"); // "testsecret"
445        let key = generate_confirmation_key(&secret, 1609459200, "conf").expect("totp error");
446
447        // Key should be valid base64
448        assert!(BASE64.decode(&key).is_ok());
449
450        // SHA-1 HMAC output is 20 bytes, base64 encoded should be 28 chars
451        assert_eq!(key.len(), 28);
452    }
453
454    #[test]
455    fn test_generate_confirmation_key_long_tag() {
456        // Tag longer than 32 characters should be truncated
457        let secret = Secret::from_base64("dGVzdHNlY3JldA==").expect("totp error");
458        let long_tag = "a".repeat(50);
459        let key = generate_confirmation_key(&secret, 1609459200, &long_tag);
460        assert!(key.is_ok());
461    }
462
463    #[test]
464    fn test_generate_confirmation_key_empty_tag() {
465        let secret = Secret::from_base64("dGVzdHNlY3JldA==").expect("totp error");
466        let key = generate_confirmation_key(&secret, 1609459200, "").expect("totp error");
467        assert!(BASE64.decode(&key).is_ok());
468    }
469
470    #[test]
471    fn test_generate_device_id_format() {
472        let steam_id = SteamID::from(76561198012345678u64);
473        let device_id = generate_device_id(steam_id, None);
474
475        // Should start with "android:"
476        assert!(device_id.starts_with("android:"));
477
478        // Should be in UUID format: android:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
479        assert_eq!(device_id.len(), 44); // "android:" (8) + UUID (36) = 44
480
481        // Check the dashes are in correct positions
482        let uuid_part = &device_id[8..];
483        let parts: Vec<&str> = uuid_part.split('-').collect();
484        assert_eq!(parts.len(), 5);
485        assert_eq!(parts[0].len(), 8);
486        assert_eq!(parts[1].len(), 4);
487        assert_eq!(parts[2].len(), 4);
488        assert_eq!(parts[3].len(), 4);
489        assert_eq!(parts[4].len(), 12);
490    }
491
492    #[test]
493    fn test_generate_device_id_consistency() {
494        // Same SteamID should produce the same device ID
495        let steam_id = SteamID::from(76561198012345678u64);
496        let id1 = generate_device_id(steam_id, None);
497        let id2 = generate_device_id(steam_id, None);
498        assert_eq!(id1, id2);
499    }
500
501    #[test]
502    fn test_generate_device_id_with_salt() {
503        let steam_id = SteamID::from(76561198012345678u64);
504        let id_no_salt = generate_device_id(steam_id, None);
505        let id_with_salt = generate_device_id(steam_id, Some("mysalt"));
506
507        // Different salt should produce different device ID
508        assert_ne!(id_no_salt, id_with_salt);
509    }
510
511    #[test]
512    fn test_time_function() {
513        let now = time(0);
514        let future = time(60);
515        let past = time(-60);
516
517        assert!(future > now);
518        assert!(past < now);
519        assert_eq!(future - now, 60);
520        assert_eq!(now - past, 60);
521    }
522}