auth_framework/protocols/
hotp.rs1use crate::errors::{AuthError, Result};
15use hmac::{Hmac, Mac};
16use ring::rand::SecureRandom;
17use sha1::Sha1;
18
19type HmacSha1 = Hmac<Sha1>;
20
21const DEFAULT_DIGITS: u32 = 6;
23
24const DEFAULT_LOOK_AHEAD: u64 = 10;
26
27#[derive(Debug, Clone)]
29pub struct HotpConfig {
30 pub digits: u32,
32
33 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
47pub struct HotpManager {
49 config: HotpConfig,
50}
51
52impl HotpManager {
53 pub fn new(config: HotpConfig) -> Self {
55 Self { config }
56 }
57
58 pub fn with_defaults() -> Self {
60 Self::new(HotpConfig::default())
61 }
62
63 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 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 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
108fn 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 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
135fn 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 #[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 let code = mgr.generate(&secret, 3).expect("generate failed");
189
190 let matched = mgr.validate(&secret, 0, &code).expect("validate failed");
192 assert_eq!(matched, Some(3));
193
194 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 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}