use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct TotpSecret(pub Vec<u8>);
impl TotpSecret {
#[must_use]
pub fn generate() -> Self {
use rand::RngCore;
let mut bytes = vec![0u8; 20];
rand::thread_rng().fill_bytes(&mut bytes);
Self(bytes)
}
#[must_use]
pub fn to_base32(&self) -> String {
base32_encode(&self.0)
}
#[must_use]
pub fn from_base32(s: &str) -> Option<Self> {
base32_decode(s).map(Self)
}
}
#[must_use]
pub fn generate(secret: &TotpSecret, step_secs: u64, digits: u32) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
generate_at(secret, now, step_secs, digits)
}
#[must_use]
pub fn generate_at(secret: &TotpSecret, unix_secs: u64, step_secs: u64, digits: u32) -> String {
let counter = unix_secs / step_secs.max(1);
hotp(&secret.0, counter, digits)
}
#[must_use]
pub fn verify(secret: &TotpSecret, code: &str, step_secs: u64, digits: u32, window: i64) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
verify_at(secret, code, now, step_secs, digits, window)
}
#[must_use]
pub fn verify_at(
secret: &TotpSecret,
code: &str,
unix_secs: u64,
step_secs: u64,
digits: u32,
window: i64,
) -> bool {
let step = step_secs.max(1) as i64;
let center = (unix_secs as i64) / step;
for offset in -window..=window {
let counter = match (center + offset).try_into() {
Ok(c) => c,
Err(_) => continue,
};
if constant_time_eq(&hotp(&secret.0, counter, digits), code) {
return true;
}
}
false
}
#[must_use]
pub fn otpauth_url(issuer: &str, account: &str, secret: &TotpSecret) -> String {
let label = format!("{}:{}", url_encode(issuer), url_encode(account));
format!(
"otpauth://totp/{label}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
secret.to_base32(),
url_encode(issuer),
)
}
fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let mut mac = <Hmac<Sha1>>::new_from_slice(secret).expect("HMAC accepts any key");
mac.update(&counter.to_be_bytes());
let hash = mac.finalize().into_bytes();
let offset = (hash[hash.len() - 1] & 0x0f) as usize;
let bin_code = ((hash[offset] & 0x7f) as u32) << 24
| (hash[offset + 1] as u32) << 16
| (hash[offset + 2] as u32) << 8
| (hash[offset + 3] as u32);
let modulo = 10u32.pow(digits.min(10));
let value = bin_code % modulo;
format!("{:0width$}", value, width = digits as usize)
}
const BASE32_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
fn base32_encode(input: &[u8]) -> String {
let mut out = String::new();
let mut buffer = 0u64;
let mut bits_left = 0;
for &b in input {
buffer = (buffer << 8) | u64::from(b);
bits_left += 8;
while bits_left >= 5 {
bits_left -= 5;
let idx = ((buffer >> bits_left) & 0x1f) as usize;
out.push(BASE32_ALPHABET[idx] as char);
}
}
if bits_left > 0 {
let idx = ((buffer << (5 - bits_left)) & 0x1f) as usize;
out.push(BASE32_ALPHABET[idx] as char);
}
out
}
fn base32_decode(input: &str) -> Option<Vec<u8>> {
let mut buffer = 0u64;
let mut bits = 0;
let mut out = Vec::new();
for c in input.chars() {
if c == '=' || c.is_whitespace() {
continue;
}
let upper = c.to_ascii_uppercase();
let val = BASE32_ALPHABET.iter().position(|&b| b == upper as u8)? as u64;
buffer = (buffer << 5) | val;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xff) as u8);
}
}
Some(out)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
if a.len() != b.len() {
return false;
}
a.as_bytes().ct_eq(b.as_bytes()).unwrap_u8() == 1
}
fn url_encode(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc6238_test_vector_sha1() {
let secret = TotpSecret(b"12345678901234567890".to_vec());
let code = generate_at(&secret, 59, 30, 8);
assert_eq!(code, "94287082");
}
#[test]
fn rfc6238_test_vector_t_1111111109() {
let secret = TotpSecret(b"12345678901234567890".to_vec());
let code = generate_at(&secret, 1_111_111_109, 30, 8);
assert_eq!(code, "07081804");
}
#[test]
fn generate_returns_correct_digit_count() {
let s = TotpSecret::generate();
for digits in [6, 7, 8] {
let c = generate(&s, 30, digits);
assert_eq!(c.len(), digits as usize);
assert!(c.chars().all(|c| c.is_ascii_digit()));
}
}
#[test]
fn verify_accepts_current_step() {
let s = TotpSecret(b"12345678901234567890".to_vec());
let code = generate_at(&s, 100, 30, 6);
assert!(verify_at(&s, &code, 100, 30, 6, 1));
}
#[test]
fn verify_accepts_within_window() {
let s = TotpSecret(b"12345678901234567890".to_vec());
let code = generate_at(&s, 100 - 30, 30, 6);
assert!(verify_at(&s, &code, 100, 30, 6, 1));
let code = generate_at(&s, 100 + 30, 30, 6);
assert!(verify_at(&s, &code, 100, 30, 6, 1));
}
#[test]
fn verify_rejects_outside_window() {
let s = TotpSecret(b"12345678901234567890".to_vec());
let now = 10_000;
let code = generate_at(&s, now - 300, 30, 6);
assert!(!verify_at(&s, &code, now, 30, 6, 1));
}
#[test]
fn verify_rejects_wrong_code() {
let s = TotpSecret(b"12345678901234567890".to_vec());
assert!(!verify_at(&s, "000000", 100, 30, 6, 1));
}
#[test]
fn base32_roundtrip() {
let original: Vec<u8> = (0..20).collect();
let encoded = base32_encode(&original);
let decoded = base32_decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn base32_decode_handles_padding_and_whitespace() {
let original = b"hello world".to_vec();
let encoded = base32_encode(&original);
let messy = format!("{} ==", encoded);
let decoded = base32_decode(&messy).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn base32_decode_invalid_returns_none() {
assert_eq!(base32_decode("not valid 0189!!"), None);
}
#[test]
fn secret_from_base32_roundtrip() {
let s1 = TotpSecret::generate();
let encoded = s1.to_base32();
let s2 = TotpSecret::from_base32(&encoded).unwrap();
assert_eq!(s1.0, s2.0);
}
#[test]
fn otpauth_url_format() {
let secret = TotpSecret(b"12345678901234567890".to_vec());
let url = otpauth_url("MyApp", "alice@example.com", &secret);
assert!(url.starts_with("otpauth://totp/MyApp:alice%40example.com?"));
assert!(url.contains("secret="));
assert!(url.contains("issuer=MyApp"));
assert!(url.contains("digits=6"));
assert!(url.contains("period=30"));
}
}