Skip to main content

auth_framework/protocols/
hotp.rs

1//! HOTP (RFC 4226) — HMAC-Based One-Time Password Algorithm
2//!
3//! Implements the HOTP algorithm as specified in RFC 4226 for counter-based
4//! one-time password generation and validation. HOTP uses a shared secret
5//! and a monotonically increasing counter to generate OTPs.
6//!
7//! # Security Considerations
8//!
9//! - Secrets must be generated with a cryptographically secure RNG
10//! - Counter values must never be reused (monotonically increasing)
11//! - Look-ahead window should be kept small to limit brute-force surface
12//! - Secrets should be stored encrypted at rest
13
14use crate::errors::{AuthError, Result};
15use hmac::{Hmac, Mac};
16use ring::rand::SecureRandom;
17use sha1::Sha1;
18
19type HmacSha1 = Hmac<Sha1>;
20
21/// Default number of digits in the HOTP code.
22const DEFAULT_DIGITS: u32 = 6;
23
24/// Default look-ahead window for counter synchronization.
25const DEFAULT_LOOK_AHEAD: u64 = 10;
26
27/// HOTP configuration.
28#[derive(Debug, Clone)]
29pub struct HotpConfig {
30    /// Number of digits in the generated OTP (6–8).
31    pub digits: u32,
32
33    /// Look-ahead window — how many counter values ahead to check
34    /// when validating a code (for counter desynchronization recovery).
35    pub look_ahead_window: u64,
36}
37
38impl Default for HotpConfig {
39    fn default() -> Self {
40        Self {
41            digits: DEFAULT_DIGITS,
42            look_ahead_window: DEFAULT_LOOK_AHEAD,
43        }
44    }
45}
46
47/// HOTP manager for generating and validating counter-based OTPs.
48pub struct HotpManager {
49    config: HotpConfig,
50}
51
52impl HotpManager {
53    /// Create a new HOTP manager with the given configuration.
54    pub fn new(config: HotpConfig) -> Self {
55        Self { config }
56    }
57
58    /// Create a new HOTP manager with default configuration.
59    pub fn with_defaults() -> Self {
60        Self::new(HotpConfig::default())
61    }
62
63    /// Generate a cryptographically random 20-byte secret encoded as
64    /// RFC 4648 Base32 (with padding).
65    pub fn generate_secret() -> Result<String> {
66        let rng = ring::rand::SystemRandom::new();
67        let mut secret = [0u8; 20];
68        rng.fill(&mut secret)
69            .map_err(|_| AuthError::crypto("Failed to generate HOTP secret"))?;
70        Ok(base32::encode(
71            base32::Alphabet::Rfc4648 { padding: true },
72            &secret,
73        ))
74    }
75
76    /// Generate an HOTP code for the given secret and counter value.
77    ///
78    /// Implements RFC 4226 §5.3 — Dynamic Truncation.
79    pub fn generate(&self, secret_b32: &str, counter: u64) -> Result<String> {
80        let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: true }, secret_b32)
81            .ok_or_else(|| AuthError::validation("Invalid Base32 secret"))?;
82
83        let code = hotp_raw(&secret, counter, self.config.digits)?;
84        Ok(code)
85    }
86
87    /// Validate an HOTP code against the expected counter value.
88    ///
89    /// On success, returns the counter value that matched (which may be
90    /// ahead of `counter` by up to `look_ahead_window`). The caller should
91    /// persist `matched_counter + 1` as the new expected counter.
92    pub fn validate(&self, secret_b32: &str, counter: u64, code: &str) -> Result<Option<u64>> {
93        let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: true }, secret_b32)
94            .ok_or_else(|| AuthError::validation("Invalid Base32 secret"))?;
95
96        for offset in 0..=self.config.look_ahead_window {
97            let candidate_counter = counter + offset;
98            let expected = hotp_raw(&secret, candidate_counter, self.config.digits)?;
99            if constant_time_eq(expected.as_bytes(), code.as_bytes()) {
100                return Ok(Some(candidate_counter));
101            }
102        }
103
104        Ok(None)
105    }
106}
107
108// ─── Internal helpers ────────────────────────────────────────────────────────
109
110/// Core HOTP computation per RFC 4226 §5.3.
111fn hotp_raw(secret: &[u8], counter: u64, digits: u32) -> Result<String> {
112    let mut mac = HmacSha1::new_from_slice(secret)
113        .map_err(|e| AuthError::crypto(format!("HMAC init failed: {e}")))?;
114
115    mac.update(&counter.to_be_bytes());
116    let result = mac.finalize().into_bytes();
117
118    // Dynamic truncation (RFC 4226 §5.3)
119    let offset = (result[19] & 0x0f) as usize;
120    let bin_code = u32::from_be_bytes([
121        result[offset] & 0x7f,
122        result[offset + 1],
123        result[offset + 2],
124        result[offset + 3],
125    ]);
126
127    let modulus = 10u32.pow(digits);
128    Ok(format!(
129        "{:0>width$}",
130        bin_code % modulus,
131        width = digits as usize
132    ))
133}
134
135/// Constant-time byte comparison to prevent timing attacks on code validation.
136fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
137    use subtle::ConstantTimeEq;
138    if a.len() != b.len() {
139        return false;
140    }
141    a.ct_eq(b).into()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    /// RFC 4226 Appendix D test vectors.
149    /// Secret = "12345678901234567890" (ASCII), digits = 6.
150    #[test]
151    fn test_rfc4226_test_vectors() {
152        let secret = b"12345678901234567890";
153        let expected: &[&str] = &[
154            "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
155            "399871", "520489",
156        ];
157
158        for (counter, &expected_code) in expected.iter().enumerate() {
159            let code = hotp_raw(secret, counter as u64, 6).expect("hotp_raw failed");
160            assert_eq!(
161                code, expected_code,
162                "RFC 4226 test vector failed for counter={counter}"
163            );
164        }
165    }
166
167    #[test]
168    fn test_generate_and_validate() {
169        let mgr = HotpManager::with_defaults();
170        let secret = HotpManager::generate_secret().expect("secret gen failed");
171
172        let code = mgr.generate(&secret, 0).expect("generate failed");
173        assert_eq!(code.len(), 6);
174
175        let matched = mgr.validate(&secret, 0, &code).expect("validate failed");
176        assert_eq!(matched, Some(0));
177    }
178
179    #[test]
180    fn test_look_ahead_window() {
181        let mgr = HotpManager::new(HotpConfig {
182            digits: 6,
183            look_ahead_window: 5,
184        });
185        let secret = HotpManager::generate_secret().expect("secret gen failed");
186
187        // Generate code for counter=3
188        let code = mgr.generate(&secret, 3).expect("generate failed");
189
190        // Validate starting from counter=0 — should find it at offset 3
191        let matched = mgr.validate(&secret, 0, &code).expect("validate failed");
192        assert_eq!(matched, Some(3));
193
194        // Validate starting from counter=0 with code for counter=10 — out of window
195        let code_far = mgr.generate(&secret, 10).expect("generate failed");
196        let not_found = mgr
197            .validate(&secret, 0, &code_far)
198            .expect("validate failed");
199        assert_eq!(not_found, None);
200    }
201
202    #[test]
203    fn test_invalid_code_rejected() {
204        let mgr = HotpManager::with_defaults();
205        let secret = HotpManager::generate_secret().expect("secret gen failed");
206
207        let matched = mgr.validate(&secret, 0, "000000").expect("validate failed");
208        // Extremely unlikely to match a random secret — but not impossible.
209        // If it does match, the test is still correct (it returns Some(0)).
210        // For practical purposes this validates the code path.
211        let _ = matched;
212    }
213
214    #[test]
215    fn test_8_digit_codes() {
216        let mgr = HotpManager::new(HotpConfig {
217            digits: 8,
218            look_ahead_window: 5,
219        });
220        let secret = HotpManager::generate_secret().expect("secret gen failed");
221
222        let code = mgr.generate(&secret, 42).expect("generate failed");
223        assert_eq!(code.len(), 8);
224
225        let matched = mgr.validate(&secret, 42, &code).expect("validate failed");
226        assert_eq!(matched, Some(42));
227    }
228}