use hmac::{Hmac, KeyInit, Mac};
use sha1::Sha1;
use subtle::ConstantTimeEq;
pub(super) const SECRET_BYTES: usize = 20;
const STEP_SECS: u64 = 30;
const DIGITS: u32 = 6;
const SKEW: u64 = 1;
pub(super) fn secret() -> [u8; SECRET_BYTES] {
let mut secret = [0; SECRET_BYTES];
getrandom::fill(&mut secret).expect("the two-factor secret randomness source failed");
secret
}
pub(super) fn encode(secret: &[u8]) -> String {
data_encoding::BASE32_NOPAD.encode(secret)
}
pub(super) fn uri(secret: &[u8], issuer: &str, account: &str) -> String {
let label: String =
percent_encoding::utf8_percent_encode(&format!("{issuer}:{account}"), URI_COMPONENT)
.collect();
let mut query = form_urlencoded::Serializer::new(String::new());
query
.append_pair("secret", &encode(secret))
.append_pair("issuer", issuer)
.append_pair("algorithm", "SHA1")
.append_pair("digits", &DIGITS.to_string())
.append_pair("period", &STEP_SECS.to_string());
format!("otpauth://totp/{label}?{}", query.finish())
}
const URI_COMPONENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
pub(super) fn step(unix_secs: u64) -> u64 {
unix_secs / STEP_SECS
}
fn hotp(secret: &[u8], counter: u64, digits: u32) -> u32 {
let mut mac = Hmac::<Sha1>::new_from_slice(secret).expect("HMAC takes a key of any length");
mac.update(&counter.to_be_bytes());
let digest = mac.finalize().into_bytes();
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
let binary = u32::from_be_bytes([
digest[offset] & 0x7f,
digest[offset + 1],
digest[offset + 2],
digest[offset + 3],
]);
binary % 10u32.pow(digits)
}
pub(super) fn verify(secret: &[u8], code: &str, now: u64, used: Option<u64>) -> Option<u64> {
let code = code.trim();
if code.len() != DIGITS as usize || !code.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let mut matched = None;
for step in now.saturating_sub(SKEW)..=now + SKEW {
let expected = format!(
"{:0width$}",
hotp(secret, step, DIGITS),
width = DIGITS as usize
);
let fresh = used.is_none_or(|used| step > used);
if bool::from(expected.as_bytes().ct_eq(code.as_bytes())) && fresh && matched.is_none() {
matched = Some(step);
}
}
matched
}
#[cfg(test)]
mod tests;