feat(core/totp): emit Steam Guard alphabet for kind=Steam
This commit is contained in:
@@ -8,6 +8,10 @@ use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
@@ -96,6 +100,16 @@ pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<S
|
||||
| ((hmac_out[offset + 1] as u32) << 16)
|
||||
| ((hmac_out[offset + 2] as u32) << 8)
|
||||
| (hmac_out[offset + 3] as u32);
|
||||
if matches!(config.kind, TotpKind::Steam) {
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let modulus = 10u32.pow(config.digits as u32);
|
||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||
}
|
||||
@@ -168,3 +182,103 @@ mod tests {
|
||||
assert!(json.contains("steam"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod steam_tests {
|
||||
use super::*;
|
||||
|
||||
/// Reference implementation of the Steam 5-character output, per the
|
||||
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
|
||||
/// Used by tests below to cross-check the production impl without
|
||||
/// requiring a third-party vector. The algorithm is short enough to
|
||||
/// be reproduced here in isolation.
|
||||
fn steam_output_reference(truncated: u32) -> String {
|
||||
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(ALPHA[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
|
||||
/// does internally — used to drive the reference impl.
|
||||
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
|
||||
mac.update(&counter.to_be_bytes());
|
||||
let bytes = mac.finalize().into_bytes();
|
||||
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
|
||||
((bytes[offset] as u32 & 0x7F) << 24)
|
||||
| ((bytes[offset + 1] as u32) << 16)
|
||||
| ((bytes[offset + 2] as u32) << 8)
|
||||
| (bytes[offset + 3] as u32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_matches_reference_impl() {
|
||||
let secret = b"12345678901234567890".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
|
||||
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
|
||||
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
|
||||
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
|
||||
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
|
||||
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
|
||||
let secret = b"hello world!".to_vec();
|
||||
for digits in [4u8, 5, 6, 7, 8] {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code = compute_totp_code(&cfg, 0).unwrap();
|
||||
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_uses_only_alphabet_chars() {
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
let secret = b"hello world!".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
for t in 0u64..1000 {
|
||||
let code = compute_totp_code(&cfg, t * 30).unwrap();
|
||||
for ch in code.chars() {
|
||||
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_alphabet_excludes_ambiguous_glyphs() {
|
||||
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
|
||||
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
|
||||
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
|
||||
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user