1use 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
28const STEAM_CHARS: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
30
31const MAX_TAG_LENGTH: usize = 32;
33
34#[derive(Debug, thiserror::Error)]
36pub enum TotpError {
37 #[error("Invalid base64 encoding: {0}")]
39 Base64Error(#[from] base64::DecodeError),
40
41 #[error("Invalid hex encoding: {0}")]
43 HexError(#[from] hex::FromHexError),
44
45 #[error("HMAC error: {0}")]
47 HmacError(String),
48
49 #[error("HTTP error: {0}")]
51 HttpError(#[from] reqwest::Error),
52
53 #[error("Malformed response: {0}")]
55 MalformedResponse(String),
56
57 #[error("System time error: {0}")]
59 TimeError(String),
60}
61
62pub type Result<T> = std::result::Result<T, TotpError>;
64
65#[derive(Debug, Clone)]
72pub struct Secret(Vec<u8>);
73
74impl Secret {
75 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Self {
77 Self(bytes.as_ref().to_vec())
78 }
79
80 pub fn from_base64(s: &str) -> Result<Self> {
82 let bytes = BASE64.decode(s)?;
83 Ok(Self(bytes))
84 }
85
86 pub fn from_hex(s: &str) -> Result<Self> {
88 let bytes = hex::decode(s)?;
89 Ok(Self(bytes))
90 }
91
92 pub fn from_string(s: &str) -> Result<Self> {
98 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 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
130pub 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
157pub 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
186pub fn generate_auth_code_for_time(secret: &Secret, time: i64) -> Result<String> {
191 let time_step = (time / 30) as u64;
193
194 let mut time_bytes = [0u8; 8];
197 time_bytes[4..8].copy_from_slice(&(time_step as u32).to_be_bytes());
198
199 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 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 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
220pub fn generate_confirmation_key(identity_secret: &Secret, time: i64, tag: &str) -> Result<String> {
251 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 let mut buffer = Vec::with_capacity(data_len);
258
259 buffer.extend_from_slice(&(time as u64).to_be_bytes());
261
262 buffer.extend_from_slice(&tag_bytes[..tag_len]);
264
265 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#[derive(Debug, Clone, Copy)]
279pub struct TimeOffsetResponse {
280 pub offset: i64,
282 pub latency_ms: u64,
284}
285
286pub 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
320pub 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 if let Some(s) = salt {
355 input.push_str(s);
356 }
357
358 let hash = Sha1::digest(input.as_bytes());
360 let hex = hex::encode(hash);
361
362 format!("android:{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32])
364}
365
366pub 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#[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 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 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 assert_eq!(code.len(), 5);
417
418 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 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 let secret = Secret::from_base64("SGVsbG9Xb3JsZDEyMzQ1Njc4OTA=").expect("totp error");
437 let code1 = generate_auth_code_for_time(&secret, 1609459200).expect("totp error"); let code2 = generate_auth_code_for_time(&secret, 1609459230).expect("totp error"); assert_ne!(code1, code2);
440 }
441
442 #[test]
443 fn test_generate_confirmation_key() {
444 let secret = Secret::from_base64("dGVzdHNlY3JldA==").expect("totp error"); let key = generate_confirmation_key(&secret, 1609459200, "conf").expect("totp error");
446
447 assert!(BASE64.decode(&key).is_ok());
449
450 assert_eq!(key.len(), 28);
452 }
453
454 #[test]
455 fn test_generate_confirmation_key_long_tag() {
456 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 assert!(device_id.starts_with("android:"));
477
478 assert_eq!(device_id.len(), 44); 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 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 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}