Skip to main content

rustauth_plugins/two_factor/
totp.rs

1use data_encoding::BASE32_NOPAD;
2use hmac::{Hmac, Mac};
3use sha1::Sha1;
4use subtle::ConstantTimeEq;
5use url::form_urlencoded;
6
7use rustauth_core::error::RustAuthError;
8
9type HmacSha1 = Hmac<Sha1>;
10
11pub fn totp_code(secret: &str, digits: u32, period: u64, unix_timestamp: i64) -> String {
12    let counter = (unix_timestamp.max(0) as u64) / period.max(1);
13    hotp(secret.as_bytes(), counter, digits)
14}
15
16pub fn verify_totp_code(secret: &str, code: &str, digits: u32, period: u64) -> bool {
17    let now = time::OffsetDateTime::now_utc().unix_timestamp();
18    [-1_i64, 0, 1].into_iter().any(|offset| {
19        let timestamp = now + (offset * period.max(1) as i64);
20        let expected = totp_code(secret, digits, period, timestamp);
21        expected.as_bytes().ct_eq(code.as_bytes()).into()
22    })
23}
24
25pub fn totp_uri(secret: &str, issuer: &str, account: &str, digits: u32, period: u64) -> String {
26    let encoded_secret = BASE32_NOPAD.encode(secret.as_bytes());
27    let encoded_issuer = component_encode(issuer);
28    let encoded_account = component_encode(account);
29    let encoded_label = format!("{encoded_issuer}:{encoded_account}");
30    let query = form_urlencoded::Serializer::new(String::new())
31        .append_pair("secret", &encoded_secret)
32        .append_pair("issuer", issuer)
33        .append_pair("algorithm", "SHA1")
34        .append_pair("digits", &digits.to_string())
35        .append_pair("period", &period.to_string())
36        .finish();
37    format!("otpauth://totp/{encoded_label}?{query}")
38}
39
40fn component_encode(value: &str) -> String {
41    form_urlencoded::byte_serialize(value.as_bytes())
42        .collect::<String>()
43        .replace('+', "%20")
44}
45
46fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
47    let Ok(mut mac) = HmacSha1::new_from_slice(secret) else {
48        return "0".repeat(digits as usize);
49    };
50    mac.update(&counter.to_be_bytes());
51    let result = mac.finalize().into_bytes();
52    let offset = (result[19] & 0x0f) as usize;
53    let binary = (u32::from(result[offset] & 0x7f) << 24)
54        | (u32::from(result[offset + 1]) << 16)
55        | (u32::from(result[offset + 2]) << 8)
56        | u32::from(result[offset + 3]);
57    let modulo = 10_u32.saturating_pow(digits);
58    format!("{:0width$}", binary % modulo, width = digits as usize)
59}
60
61pub fn validate_digits(digits: u32) -> Result<(), RustAuthError> {
62    if matches!(digits, 6 | 8) {
63        Ok(())
64    } else {
65        Err(RustAuthError::InvalidConfig(
66            "two factor TOTP digits must be 6 or 8".to_owned(),
67        ))
68    }
69}