1use hmac::{Hmac, Mac};
23use sha1::Sha1;
24use sha2::{Sha256, Sha512};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum HashAlgorithm {
33 Sha1,
35 Sha256,
37 Sha512,
39}
40
41pub fn hotp(secret: &[u8], counter: u64, digits: u32, algo: HashAlgorithm) -> u32 {
56 let counter_bytes = counter.to_be_bytes();
58
59 let hmac_result = compute_hmac(secret, &counter_bytes, algo);
61
62 let offset = (hmac_result.last().expect("HMAC output non-empty") & 0x0F) as usize;
64 let truncated = u32::from_be_bytes([
65 hmac_result[offset],
66 hmac_result[offset + 1],
67 hmac_result[offset + 2],
68 hmac_result[offset + 3],
69 ]) & 0x7FFF_FFFF;
70
71 truncated % (10_u32.pow(digits))
72}
73
74pub fn totp(
90 secret: &[u8],
91 timestamp: u64,
92 time_step: u64,
93 digits: u32,
94 algo: HashAlgorithm,
95) -> u32 {
96 let counter = timestamp / time_step;
97 hotp(secret, counter, digits, algo)
98}
99
100pub fn format_code(code: u32, digits: u32) -> String {
102 format!("{:0>width$}", code, width = digits as usize)
103}
104
105pub fn hotp_uri(
111 secret: &[u8],
112 issuer: &str,
113 account: &str,
114 counter: u64,
115 digits: u32,
116 algo: HashAlgorithm,
117) -> String {
118 let secret_b32 = base32_encode(secret);
119 let algo_name = algo_str(algo);
120 format!(
121 "otpauth://hotp/{}:{}?secret={}&issuer={}&counter={}&digits={}&algorithm={}",
122 url_encode(issuer),
123 url_encode(account),
124 secret_b32,
125 url_encode(issuer),
126 counter,
127 digits,
128 algo_name,
129 )
130}
131
132pub fn totp_uri(
134 secret: &[u8],
135 issuer: &str,
136 account: &str,
137 period: u64,
138 digits: u32,
139 algo: HashAlgorithm,
140) -> String {
141 let secret_b32 = base32_encode(secret);
142 let algo_name = algo_str(algo);
143 format!(
144 "otpauth://totp/{}:{}?secret={}&issuer={}&period={}&digits={}&algorithm={}",
145 url_encode(issuer),
146 url_encode(account),
147 secret_b32,
148 url_encode(issuer),
149 period,
150 digits,
151 algo_name,
152 )
153}
154
155fn compute_hmac(secret: &[u8], message: &[u8], algo: HashAlgorithm) -> Vec<u8> {
160 match algo {
161 HashAlgorithm::Sha1 => {
162 type HmacSha1 = Hmac<Sha1>;
163 let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
164 mac.update(message);
165 mac.finalize().into_bytes().to_vec()
166 }
167 HashAlgorithm::Sha256 => {
168 type HmacSha256 = Hmac<Sha256>;
169 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
170 mac.update(message);
171 mac.finalize().into_bytes().to_vec()
172 }
173 HashAlgorithm::Sha512 => {
174 type HmacSha512 = Hmac<Sha512>;
175 let mut mac = HmacSha512::new_from_slice(secret).expect("HMAC accepts any key length");
176 mac.update(message);
177 mac.finalize().into_bytes().to_vec()
178 }
179 }
180}
181
182fn algo_str(algo: HashAlgorithm) -> &'static str {
183 match algo {
184 HashAlgorithm::Sha1 => "SHA1",
185 HashAlgorithm::Sha256 => "SHA256",
186 HashAlgorithm::Sha512 => "SHA512",
187 }
188}
189
190fn base32_encode(data: &[u8]) -> String {
192 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
193 let mut out = String::new();
194 let mut buffer: u64 = 0;
195 let mut bits = 0u32;
196
197 for &byte in data {
198 buffer = (buffer << 8) | (byte as u64);
199 bits += 8;
200 while bits >= 5 {
201 bits -= 5;
202 let idx = ((buffer >> bits) & 0x1F) as usize;
203 out.push(ALPHABET[idx] as char);
204 }
205 }
206 if bits > 0 {
207 let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
208 out.push(ALPHABET[idx] as char);
209 }
210 out
211}
212
213fn url_encode(s: &str) -> String {
215 let mut out = String::new();
216 for byte in s.bytes() {
217 match byte {
218 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
219 out.push(byte as char);
220 }
221 _ => {
222 out.push_str(&format!("%{:02X}", byte));
223 }
224 }
225 }
226 out
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236
237 const RFC_SECRET: &[u8] = b"12345678901234567890";
239
240 #[test]
241 fn rfc4226_test_vectors() {
242 let expected = [
243 755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871, 520489,
244 ];
245 for (counter, &expected_code) in expected.iter().enumerate() {
246 let code = hotp(RFC_SECRET, counter as u64, 6, HashAlgorithm::Sha1);
247 assert_eq!(code, expected_code, "HOTP mismatch at counter {counter}");
248 }
249 }
250
251 #[test]
252 fn totp_deterministic() {
253 let secret = b"test-secret";
254 let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
255 let code2 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
256 assert_eq!(code1, code2);
257 }
258
259 #[test]
260 fn totp_different_timestamps() {
261 let secret = b"test-secret";
262 let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
264 let code2 = totp(secret, 1718000031, 30, 6, HashAlgorithm::Sha256);
265 assert_ne!(code1, code2);
267 }
268
269 #[test]
270 fn digits_6_7_8() {
271 let secret = b"test-secret";
272 let c6 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
273 let c7 = hotp(secret, 0, 7, HashAlgorithm::Sha256);
274 let c8 = hotp(secret, 0, 8, HashAlgorithm::Sha256);
275 assert!(c6 < 1_000_000, "6-digit code out of range");
276 assert!(c7 < 10_000_000, "7-digit code out of range");
277 assert!(c8 < 100_000_000, "8-digit code out of range");
278 }
279
280 #[test]
281 fn format_code_leading_zeros() {
282 assert_eq!(format_code(42, 6), "000042");
283 assert_eq!(format_code(123456, 6), "123456");
284 assert_eq!(format_code(7, 8), "00000007");
285 }
286
287 #[test]
288 fn base32_encoding() {
289 assert_eq!(base32_encode(b"Hello"), "JBSWY3DP");
291 assert_eq!(base32_encode(b"\x00"), "AA");
292 assert_eq!(base32_encode(b"f"), "MY");
293 }
294
295 #[test]
296 fn hotp_uri_format() {
297 let uri = hotp_uri(
298 b"secret",
299 "TestApp",
300 "user@example.com",
301 0,
302 6,
303 HashAlgorithm::Sha256,
304 );
305 assert!(uri.starts_with("otpauth://hotp/"));
306 assert!(uri.contains("algorithm=SHA256"));
307 assert!(uri.contains("counter=0"));
308 assert!(uri.contains("digits=6"));
309 }
310
311 #[test]
312 fn totp_uri_format() {
313 let uri = totp_uri(
314 b"secret",
315 "TestApp",
316 "user@example.com",
317 30,
318 6,
319 HashAlgorithm::Sha256,
320 );
321 assert!(uri.starts_with("otpauth://totp/"));
322 assert!(uri.contains("period=30"));
323 }
324
325 #[test]
326 fn different_algorithms_different_codes() {
327 let secret = b"test-secret";
328 let c_sha1 = hotp(secret, 0, 6, HashAlgorithm::Sha1);
329 let c_sha256 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
330 let c_sha512 = hotp(secret, 0, 6, HashAlgorithm::Sha512);
331 assert_ne!(c_sha1, c_sha256);
333 assert_ne!(c_sha256, c_sha512);
334 }
335}