use data_encoding::BASE32;
use hmac::{Hmac, Mac};
use sha1::Sha1;
use crate::error::{Error, Result};
type HmacSha1 = Hmac<Sha1>;
const DIGITS: usize = 6;
const STEP: u64 = 30;
pub fn totp_at(secret_b32: &str, unix_secs: u64) -> Result<String> {
let key = decode_base32(secret_b32)?;
Ok(totp_raw(&key, unix_secs))
}
pub fn current_totp(secret_b32: &str) -> Result<String> {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| Error::Other(format!("system clock before epoch: {e}")))?
.as_secs();
totp_at(secret_b32, secs)
}
fn totp_raw(key: &[u8], unix_secs: u64) -> String {
let counter = unix_secs / STEP;
format_digits(hotp(key, counter), DIGITS)
}
fn hotp(key: &[u8], counter: u64) -> u32 {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(&counter.to_be_bytes());
let digest = mac.finalize().into_bytes();
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
let bytes = &digest[offset..offset + 4];
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7fff_ffff
}
fn format_digits(value: u32, n: usize) -> String {
let modulus = 10u32.pow(n as u32);
format!("{:0>width$}", value % modulus, width = n)
}
fn decode_base32(secret: &str) -> Result<Vec<u8>> {
let cleaned: String = secret.chars().filter(|c| !c.is_whitespace()).collect();
if cleaned.is_empty() {
return Err(Error::Other("no totp secret".into()));
}
let upper = cleaned.to_uppercase();
let pad = (8 - (upper.len() % 8)) % 8;
let padded = format!("{upper}{:=<pad$}", "");
BASE32
.decode(padded.as_bytes())
.map_err(|e| Error::Other(format!("invalid base32 totp secret: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn hotp_digits(key: &[u8], counter: u64, digits: usize) -> String {
format_digits(hotp(key, counter), digits)
}
#[test]
fn rfc6238_vectors_8_digit() {
let key = b"12345678901234567890";
let cases: &[(u64, &str)] = &[
(59, "94287082"),
(1111111109, "07081804"),
(1111111111, "14050471"),
];
for &(secs, expected) in cases {
let got = hotp_digits(key, secs / STEP, 8);
assert_eq!(
got, expected,
"RFC 6238 vector mismatch at secs={secs}: got {got}, want {expected}"
);
}
}
#[test]
fn rfc6238_via_totp_raw_matches_hotp() {
let key = b"12345678901234567890";
let secs = 59u64;
let raw = totp_raw(key, secs);
let direct = hotp_digits(key, secs / STEP, 6);
assert_eq!(raw, direct);
assert_eq!(raw.len(), 6);
}
#[test]
fn current_and_at_are_consistent() {
let code = totp_at("JBSWY3DPEHPK3PXP", 1_000_000).unwrap();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
let now = current_totp("JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(now.len(), 6);
assert!(now.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn totp_changes_with_step() {
let key = b"12345678901234567890";
let a = totp_raw(key, 0);
let b = totp_raw(key, 30);
assert_ne!(a, b);
}
#[test]
fn base32_accepts_lowercase_and_spaces() {
let a = totp_at("jbswy 3dpeh pk3pxp", 1_000_000).unwrap();
let b = totp_at("JBSWY3DPEHPK3PXP", 1_000_000).unwrap();
assert_eq!(a, b);
}
#[test]
fn base32_invalid_errors() {
assert!(totp_at("JBSWY3DPEHPK3P!@", 1_000_000).is_err());
assert!(totp_at("JBSW3DPEHPK3PXP", 1_000_000).is_err());
}
#[test]
fn base32_empty_errors() {
assert!(matches!(
totp_at("", 1_000_000),
Err(Error::Other(msg)) if msg.contains("no totp secret")
));
assert!(totp_at(" ", 1_000_000).is_err());
}
#[test]
fn format_digits_leading_zeros() {
assert_eq!(format_digits(7, 6), "000007");
assert_eq!(format_digits(123456, 6), "123456");
assert_eq!(format_digits(1_234_567, 6), "234567");
}
}