Skip to main content

steam_client/services/
twofactor.rs

1//! Two-factor authentication (2FA) management.
2//!
3//! This module provides functionality to enable and finalize TOTP
4//! two-factor authentication on Steam accounts.
5
6use crate::{error::SteamError, SteamClient};
7
8/// Two-factor authentication secrets returned when enabling 2FA.
9#[derive(Debug, Clone)]
10pub struct TwoFactorSecrets {
11    /// The shared secret used to generate TOTP codes (base64 encoded).
12    pub shared_secret: String,
13    /// The identity secret used for trade confirmations (base64 encoded).
14    pub identity_secret: String,
15    /// Secret 1 (base64 encoded).
16    pub secret_1: String,
17    /// The revocation code to disable 2FA.
18    pub revocation_code: String,
19    /// Serial number of the authenticator.
20    pub serial_number: u64,
21    /// URI for adding to authenticator apps.
22    pub uri: Option<String>,
23    /// Steam server time when 2FA was enabled.
24    pub server_time: u64,
25    /// Account name.
26    pub account_name: Option<String>,
27    /// Phone number hint (last digits).
28    pub phone_number_hint: Option<String>,
29    /// Status code from Steam.
30    pub status: i32,
31}
32
33impl SteamClient {
34    /// Enable TOTP two-factor authentication on this account.
35    ///
36    /// This begins the 2FA setup process. Steam will send an SMS with
37    /// an activation code that must be passed to [`finalize_two_factor`].
38    ///
39    /// # Returns
40    ///
41    /// Returns [`TwoFactorSecrets`] containing the shared secret and other
42    /// data needed to generate TOTP codes. **Save these securely!**
43    ///
44    /// # Example
45    ///
46    /// ```rust,ignore
47    /// let secrets = client.enable_two_factor().await?;
48    /// tracing::info!("Shared secret: {}", secrets.shared_secret);
49    /// tracing::info!("Revocation code: {}", secrets.revocation_code);
50    /// // Wait for SMS, then call finalize_two_factor
51    /// ```
52    pub async fn enable_two_factor(&mut self) -> Result<TwoFactorSecrets, SteamError> {
53        if !self.is_logged_in() {
54            return Err(SteamError::NotLoggedOn);
55        }
56
57        let steam_id = self.steam_id.as_ref().ok_or(SteamError::NotLoggedOn)?.steam_id64();
58
59        // Generate device identifier (similar to steam-totp getDeviceID)
60        let device_id = format!("android:{}", uuid::Uuid::new_v4());
61
62        let request = steam_protos::CTwoFactorAddAuthenticatorRequest {
63            steamid: Some(steam_id),
64            authenticator_type: Some(1), // TOTP
65            device_identifier: Some(device_id),
66            sms_phone_id: Some("1".to_string()),
67            version: Some(2),
68            http_headers: vec![],
69            ..Default::default()
70        };
71
72        // Send unified message - this would need the unified message infrastructure
73        // For now, we'll use the service method pattern
74        let response: steam_protos::CTwoFactorAddAuthenticatorResponse = self.send_unified_message("TwoFactor.AddAuthenticator#1", &request).await?;
75
76        use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
77
78        Ok(TwoFactorSecrets {
79            shared_secret: BASE64_STANDARD.encode(response.shared_secret.ok_or_else(|| SteamError::Other("Missing shared_secret".into()))?),
80            identity_secret: BASE64_STANDARD.encode(response.identity_secret.ok_or_else(|| SteamError::Other("Missing identity_secret".into()))?),
81            secret_1: BASE64_STANDARD.encode(response.secret_1.ok_or_else(|| SteamError::Other("Missing secret_1".into()))?),
82            revocation_code: response.revocation_code.ok_or_else(|| SteamError::Other("Missing revocation_code".into()))?,
83            serial_number: response.serial_number.unwrap_or_default(),
84            uri: response.uri,
85            server_time: response.server_time.unwrap_or_default(),
86            account_name: response.account_name,
87            phone_number_hint: response.phone_number_hint,
88            status: response.status.unwrap_or_default(),
89        })
90    }
91
92    /// Finalize the two-factor authentication setup.
93    ///
94    /// After calling [`enable_two_factor`], Steam sends an SMS with an
95    /// activation code. Call this method with that code to complete the 2FA
96    /// setup.
97    ///
98    /// # Arguments
99    ///
100    /// * `shared_secret` - The shared secret returned from
101    ///   [`enable_two_factor`]
102    /// * `activation_code` - The SMS activation code from Steam
103    ///
104    /// # Example
105    ///
106    /// ```rust,ignore
107    /// // After receiving SMS code
108    /// client.finalize_two_factor(&secrets.shared_secret, "ABC123").await?;
109    /// tracing::info!("2FA enabled successfully!");
110    /// ```
111    pub async fn finalize_two_factor(&mut self, shared_secret: &str, activation_code: &str) -> Result<(), SteamError> {
112        if !self.is_logged_in() {
113            return Err(SteamError::NotLoggedOn);
114        }
115
116        let steam_id = self.steam_id.as_ref().ok_or(SteamError::NotLoggedOn)?.steam_id64();
117
118        let mut diff: i64 = 0;
119        let mut attempts_left = 30;
120
121        loop {
122            // Get current time
123            let current_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| SteamError::Other(e.to_string()))?.as_secs();
124
125            // Generate TOTP code from shared secret with time offset
126            let time_for_code = (current_time as i64) + diff;
127            let auth_code = generate_totp_code(shared_secret, time_for_code)?;
128
129            let request = steam_protos::CTwoFactorFinalizeAddAuthenticatorRequest {
130                steamid: Some(steam_id),
131                authenticator_code: Some(auth_code),
132                authenticator_time: Some(current_time),
133                activation_code: Some(activation_code.to_string()),
134                http_headers: vec![],
135                validate_sms_code: None,
136            };
137
138            let response: steam_protos::CTwoFactorFinalizeAddAuthenticatorResponse = self.send_unified_message("TwoFactor.FinalizeAddAuthenticator#1", &request).await?;
139
140            if let Some(server_time) = response.server_time {
141                diff = (server_time as i64) - (current_time as i64);
142            }
143
144            if response.status == Some(89) {
145                return Err(SteamError::Other("Invalid activation code".into()));
146            }
147
148            if response.success == Some(true) {
149                return Ok(());
150            }
151
152            if response.want_more == Some(true) {
153                attempts_left -= 1;
154                diff += 30;
155
156                if attempts_left <= 0 {
157                    return Err(SteamError::Other("Failed to finalize adding authenticator after 30 attempts".into()));
158                }
159                continue;
160            }
161
162            return Err(SteamError::Other(format!("Error {}", response.status.unwrap_or(0))));
163        }
164    }
165
166    /// Send a unified service message (internal helper).
167    #[allow(dead_code)]
168    async fn send_unified_message<Req: prost::Message, Res: prost::Message + Default>(&mut self, method: &str, body: &Req) -> Result<Res, SteamError> {
169        // Unified messages use a special job-based format
170        // For now, return not implemented
171        tracing::debug!("Would send unified message: {}", method);
172        let _ = body.encode_to_vec();
173        Err(SteamError::NotImplemented(format!("Unified message {} not yet implemented", method)))
174    }
175}
176
177/// Generate a TOTP code from a shared secret.
178///
179/// This uses the `steam-totp` crate for proper Steam TOTP generation.
180fn generate_totp_code(shared_secret: &str, time: i64) -> Result<String, SteamError> {
181    let secret = steam_totp::Secret::from_string(shared_secret).map_err(|e| SteamError::Other(format!("Invalid shared secret: {}", e)))?;
182    steam_totp::generate_auth_code_for_time(&secret, time).map_err(|e| SteamError::Other(format!("TOTP generation failed: {}", e)))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_totp_generation() {
191        // Test with a known secret (this is just a test, not a real secret)
192        let secret = "SGVsbG9Xb3JsZDEyMzQ1Njc4OTA="; // Base64 encoded test data
193        let result = generate_totp_code(secret, 1609459200); // 2021-01-01 00:00:00 UTC
194        assert!(result.is_ok());
195        let code = result.unwrap();
196        assert_eq!(code.len(), 5);
197        // All characters should be from Steam's alphabet
198        for c in code.chars() {
199            assert!("23456789BCDFGHJKMNPQRTVWXY".contains(c));
200        }
201    }
202}