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
155pub(crate) fn 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
190pub fn base32_encode(data: &[u8]) -> String {
196 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
197 let mut out = String::new();
198 let mut buffer: u64 = 0;
199 let mut bits = 0u32;
200
201 for &byte in data {
202 buffer = (buffer << 8) | (byte as u64);
203 bits += 8;
204 while bits >= 5 {
205 bits -= 5;
206 let idx = ((buffer >> bits) & 0x1F) as usize;
207 out.push(ALPHABET[idx] as char);
208 }
209 }
210 if bits > 0 {
211 let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
212 out.push(ALPHABET[idx] as char);
213 }
214 out
215}
216
217pub fn base32_decode(s: &str) -> Result<Vec<u8>, String> {
226 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
227 let mut acc: u64 = 0;
228 let mut bits = 0u32;
229 let mut out = Vec::with_capacity(s.len() * 5 / 8);
230 for byte in s.bytes() {
231 if byte == b'=' {
232 break; }
234 let v = match ALPHABET.iter().position(|&c| c == byte) {
235 Some(p) => p as u64,
236 None => return Err(format!("invalid base32 character: {:?}", byte as char)),
237 };
238 acc = (acc << 5) | v;
239 bits += 5;
240 if bits >= 8 {
241 bits -= 8;
242 out.push(((acc >> bits) & 0xff) as u8);
243 }
244 }
245 Ok(out)
246}
247
248fn url_encode(s: &str) -> String {
250 let mut out = String::new();
251 for byte in s.bytes() {
252 match byte {
253 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
254 out.push(byte as char);
255 }
256 _ => {
257 out.push_str(&format!("%{:02X}", byte));
258 }
259 }
260 }
261 out
262}
263
264#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const RFC_SECRET: &[u8] = b"12345678901234567890";
274
275 #[test]
276 fn rfc4226_test_vectors() {
277 let expected = [
278 755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871, 520489,
279 ];
280 for (counter, &expected_code) in expected.iter().enumerate() {
281 let code = hotp(RFC_SECRET, counter as u64, 6, HashAlgorithm::Sha1);
282 assert_eq!(code, expected_code, "HOTP mismatch at counter {counter}");
283 }
284 }
285
286 #[test]
287 fn totp_deterministic() {
288 let secret = b"test-secret";
289 let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
290 let code2 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
291 assert_eq!(code1, code2);
292 }
293
294 #[test]
295 fn totp_different_timestamps() {
296 let secret = b"test-secret";
297 let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
299 let code2 = totp(secret, 1718000031, 30, 6, HashAlgorithm::Sha256);
300 assert_ne!(code1, code2);
302 }
303
304 #[test]
305 fn digits_6_7_8() {
306 let secret = b"test-secret";
307 let c6 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
308 let c7 = hotp(secret, 0, 7, HashAlgorithm::Sha256);
309 let c8 = hotp(secret, 0, 8, HashAlgorithm::Sha256);
310 assert!(c6 < 1_000_000, "6-digit code out of range");
311 assert!(c7 < 10_000_000, "7-digit code out of range");
312 assert!(c8 < 100_000_000, "8-digit code out of range");
313 }
314
315 #[test]
316 fn format_code_leading_zeros() {
317 assert_eq!(format_code(42, 6), "000042");
318 assert_eq!(format_code(123456, 6), "123456");
319 assert_eq!(format_code(7, 8), "00000007");
320 }
321
322 #[test]
323 fn base32_encoding() {
324 assert_eq!(base32_encode(b"Hello"), "JBSWY3DP");
326 assert_eq!(base32_encode(b"\x00"), "AA");
327 assert_eq!(base32_encode(b"f"), "MY");
328 }
329
330 #[test]
331 fn base32_round_trip_and_rfc4648_vectors() {
332 assert_eq!(base32_decode("JBSWY3DP").unwrap(), b"Hello");
334 assert_eq!(base32_decode("AA").unwrap(), b"\x00");
335 assert_eq!(base32_decode("MY").unwrap(), b"f");
336
337 assert_eq!(base32_encode(b"f"), "MY"); assert_eq!(base32_encode(b"fo"), "MZXQ"); assert_eq!(base32_encode(b"foo"), "MZXW6"); assert_eq!(base32_encode(b"foob"), "MZXW6YQ"); assert_eq!(base32_encode(b"fooba"), "MZXW6YTB"); assert_eq!(base32_encode(b"foobar"), "MZXW6YTBOI"); assert_eq!(base32_decode("MZXW6").unwrap(), b"foo");
349 assert_eq!(base32_decode("MZXW6YQ").unwrap(), b"foob");
350 assert_eq!(base32_decode("MZXW6YTB").unwrap(), b"fooba");
351 assert_eq!(base32_decode("MZXW6YTBOI").unwrap(), b"foobar");
352
353 assert_eq!(base32_decode("MZXW6YQ=").unwrap(), b"foob");
355 assert_eq!(base32_decode("MY======").unwrap(), b"f");
356
357 assert!(base32_decode("JBSWY3D!").is_err());
359 assert!(base32_decode("jbswy3dp").is_err());
361 }
362
363 #[test]
364 fn hotp_uri_format() {
365 let uri = hotp_uri(
366 b"secret",
367 "TestApp",
368 "user@example.com",
369 0,
370 6,
371 HashAlgorithm::Sha256,
372 );
373 assert!(uri.starts_with("otpauth://hotp/"));
374 assert!(uri.contains("algorithm=SHA256"));
375 assert!(uri.contains("counter=0"));
376 assert!(uri.contains("digits=6"));
377 }
378
379 #[test]
380 fn totp_uri_format() {
381 let uri = totp_uri(
382 b"secret",
383 "TestApp",
384 "user@example.com",
385 30,
386 6,
387 HashAlgorithm::Sha256,
388 );
389 assert!(uri.starts_with("otpauth://totp/"));
390 assert!(uri.contains("period=30"));
391 }
392
393 #[test]
394 fn different_algorithms_different_codes() {
395 let secret = b"test-secret";
396 let c_sha1 = hotp(secret, 0, 6, HashAlgorithm::Sha1);
397 let c_sha256 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
398 let c_sha512 = hotp(secret, 0, 6, HashAlgorithm::Sha512);
399 assert_ne!(c_sha1, c_sha256);
401 assert_ne!(c_sha256, c_sha512);
402 }
403}