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};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct TotpCore {
|
pub struct TotpCore {
|
||||||
pub config: TotpConfig,
|
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 + 1] as u32) << 16)
|
||||||
| ((hmac_out[offset + 2] as u32) << 8)
|
| ((hmac_out[offset + 2] as u32) << 8)
|
||||||
| (hmac_out[offset + 3] as u32);
|
| (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);
|
let modulus = 10u32.pow(config.digits as u32);
|
||||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||||
}
|
}
|
||||||
@@ -168,3 +182,103 @@ mod tests {
|
|||||||
assert!(json.contains("steam"));
|
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