use anyhow::{anyhow, bail, Context, Result};
use hmac::{Hmac, Mac};
use sha1::Sha1;
use sha2::{Sha256, Sha512};
#[derive(Clone, Copy)]
enum Algo {
Sha1,
Sha256,
Sha512,
}
pub struct Totp {
secret: Vec<u8>,
algo: Algo,
digits: u32,
period: u64,
}
impl Totp {
pub fn parse(input: &str) -> Result<Self> {
let mut secret_b32 = String::new();
let mut algo = Algo::Sha256;
let mut digits = 6u32;
let mut period = 30u64;
if input.trim_start().starts_with("otpauth://") {
let query = input.split_once('?').map(|(_, q)| q).unwrap_or("");
for (k, v) in query.split('&').filter_map(|kv| kv.split_once('=')) {
let v = pct_decode(v);
match k {
"secret" => secret_b32 = v,
"algorithm" => {
algo = match v.to_ascii_uppercase().as_str() {
"SHA1" => Algo::Sha1,
"SHA256" => Algo::Sha256,
"SHA512" => Algo::Sha512,
other => bail!("unsupported TOTP algorithm: {other}"),
}
}
"digits" => digits = v.parse().context("bad digits in otpauth URI")?,
"period" => period = v.parse().context("bad period in otpauth URI")?,
_ => {}
}
}
if secret_b32.is_empty() {
bail!("otpauth URI has no secret");
}
} else {
secret_b32 = input.trim().to_string();
}
let cleaned: String = secret_b32
.chars()
.filter(|c| !c.is_whitespace() && *c != '=')
.collect();
let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &cleaned)
.ok_or_else(|| anyhow!("secret is not valid base32"))?;
if secret.is_empty() {
bail!("empty TOTP secret");
}
Ok(Totp {
secret,
algo,
digits,
period,
})
}
pub fn code_at(&self, unix_time: u64) -> String {
let counter = (unix_time / self.period).to_be_bytes();
macro_rules! mac {
($h:ty) => {{
let mut m = <Hmac<$h> as Mac>::new_from_slice(&self.secret)
.expect("HMAC accepts any key length");
m.update(&counter);
m.finalize().into_bytes().to_vec()
}};
}
let digest: Vec<u8> = match self.algo {
Algo::Sha1 => mac!(Sha1),
Algo::Sha256 => mac!(Sha256),
Algo::Sha512 => mac!(Sha512),
};
let off = (digest[digest.len() - 1] & 0x0f) as usize;
let bin = ((u32::from(digest[off]) & 0x7f) << 24)
| (u32::from(digest[off + 1]) << 16)
| (u32::from(digest[off + 2]) << 8)
| u32::from(digest[off + 3]);
let code = bin % 10u32.pow(self.digits);
format!("{code:0width$}", width = self.digits as usize)
}
}
fn pct_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
const SEED_SHA1: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
const SEED_SHA256: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA";
#[test]
fn rfc6238_sha1() {
let t = Totp::parse(SEED_SHA1).unwrap();
let t8 = Totp {
digits: 8,
algo: Algo::Sha1,
..t
};
assert_eq!(t8.code_at(59), "94287082");
assert_eq!(t8.code_at(1111111109), "07081804");
}
#[test]
fn rfc6238_sha256() {
let t = Totp::parse(SEED_SHA256).unwrap();
let t8 = Totp { digits: 8, ..t };
assert_eq!(t8.code_at(59), "46119246");
assert_eq!(t8.code_at(1111111109), "68084774");
}
#[test]
fn parses_otpauth_uri_algorithm() {
let uri = "otpauth://totp/Certum:me@example.com?secret=GEZDGNBVGY3TQOJQ&issuer=Certum&algorithm=SHA1&digits=8&period=30";
let t = Totp::parse(uri).unwrap();
assert!(matches!(t.algo, Algo::Sha1));
assert_eq!(t.digits, 8);
}
}