use hmac::{Hmac, Mac};
use sha1::Sha1;
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha1 = Hmac<Sha1>;
const STEP_SECONDS: u64 = 30;
const DIGITS: u32 = 6;
pub fn decode_base32(seed: &str) -> Option<Vec<u8>> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = Vec::with_capacity(seed.len() * 5 / 8 + 1);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
for c in seed.chars().filter(|c| *c != '=' && !c.is_whitespace()) {
let upper = c.to_ascii_uppercase();
let pos = ALPHABET.iter().position(|&a| a == upper as u8)?;
buf = (buf << 5) | pos as u32;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
buf &= (1u32 << bits) - 1;
}
}
Some(out)
}
pub fn code_at(secret: &[u8], unix_seconds: u64) -> u32 {
let counter = unix_seconds / STEP_SECONDS;
hotp(secret, counter)
}
pub fn verify_str(seed_base32: &str, code: &str, window_steps: i64) -> bool {
if code.len() != DIGITS as usize || !code.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let Ok(parsed) = code.parse::<u32>() else {
return false;
};
verify(seed_base32, parsed, SystemTime::now(), window_steps)
}
pub fn verify(seed_base32: &str, code: u32, now: SystemTime, window_steps: i64) -> bool {
let Some(secret) = decode_base32(seed_base32) else {
return false;
};
let Ok(secs) = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()) else {
return false;
};
let center = (secs / STEP_SECONDS) as i64;
for offset in -window_steps..=window_steps {
let slice = center + offset;
if slice < 0 {
continue;
}
let counter = slice as u64;
if hotp(&secret, counter) == code {
return true;
}
}
false
}
pub fn verify_consecutive(
seed_base32: &str,
code1: u32,
code2: u32,
now: SystemTime,
window_steps: i64,
) -> bool {
let Some(secret) = decode_base32(seed_base32) else {
return false;
};
let Ok(secs) = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()) else {
return false;
};
let center = (secs / STEP_SECONDS) as i64;
for offset in -window_steps..=window_steps {
let slice = center + offset;
if slice < 0 {
continue;
}
let c1 = hotp(&secret, slice as u64);
let c2 = hotp(&secret, (slice + 1) as u64);
if c1 == code1 && c2 == code2 {
return true;
}
}
false
}
fn hotp(secret: &[u8], counter: u64) -> u32 {
let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(&counter.to_be_bytes());
let digest = mac.finalize().into_bytes();
let offset = (digest[19] & 0x0f) as usize;
let bin = ((digest[offset] as u32 & 0x7f) << 24)
| ((digest[offset + 1] as u32) << 16)
| ((digest[offset + 2] as u32) << 8)
| (digest[offset + 3] as u32);
bin % 10u32.pow(DIGITS)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn rfc6238_vector_t59() {
let secret = b"12345678901234567890";
let counter = 59 / STEP_SECONDS;
assert_eq!(hotp(secret, counter), 287082);
}
#[test]
fn rfc6238_vector_t1111111109() {
let secret = b"12345678901234567890";
let counter = 1_111_111_109 / STEP_SECONDS;
assert_eq!(hotp(secret, counter), 81804);
}
#[test]
fn base32_round_trip_aws_example() {
let decoded = decode_base32("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").unwrap();
assert_eq!(decoded, b"12345678901234567890");
}
#[test]
fn base32_handles_lowercase_and_whitespace() {
let decoded = decode_base32("ge zd gn bv").unwrap();
let upper = decode_base32("GEZDGNBV").unwrap();
assert_eq!(decoded, upper);
}
#[test]
fn base32_rejects_invalid_character() {
assert!(decode_base32("0123!@").is_none());
}
#[test]
fn verify_accepts_matching_code_within_window() {
let seed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
let secret = decode_base32(seed).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(59);
let code = hotp(&secret, 59 / STEP_SECONDS);
assert!(verify(seed, code, now, 1));
}
#[test]
fn verify_rejects_wrong_code() {
let seed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
let now = UNIX_EPOCH + Duration::from_secs(59);
assert!(!verify(seed, 999_999, now, 1));
}
#[test]
fn verify_consecutive_accepts_back_to_back_codes() {
let seed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
let secret = decode_base32(seed).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(60);
let slice = 60 / STEP_SECONDS;
let code1 = hotp(&secret, slice);
let code2 = hotp(&secret, slice + 1);
assert!(verify_consecutive(seed, code1, code2, now, 1));
}
#[test]
fn verify_consecutive_rejects_swapped_order() {
let seed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
let secret = decode_base32(seed).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(60);
let slice = 60 / STEP_SECONDS;
let code1 = hotp(&secret, slice);
let code2 = hotp(&secret, slice + 1);
assert!(!verify_consecutive(seed, code2, code1, now, 1));
}
#[test]
fn verify_with_malformed_seed_returns_false() {
let now = UNIX_EPOCH + Duration::from_secs(59);
assert!(!verify("not-base32!@#", 0, now, 1));
}
}