1use crate::{Algorithm, Secret, encoding, hmac};
2
3pub struct Hotp {
4 alg: Algorithm,
5 issuer: String,
6 label: String,
7 digits: u8,
8 counter: u64,
9 secret: Secret,
10}
11
12impl Default for Hotp {
13 fn default() -> Self {
14 Self {
15 alg: Algorithm::default(),
16 issuer: String::new(),
17 label: String::new(),
18 digits: 6,
19 counter: 0,
20 secret: Default::default(),
21 }
22 }
23}
24
25impl Hotp {
26 pub fn new(
56 alg: Algorithm,
57 issuer: String,
58 label: String,
59 digits: u8,
60 counter: u64,
61 secret: Secret,
62 ) -> Self {
63 Self {
64 alg,
65 issuer,
66 label,
67 digits,
68 counter,
69 secret,
70 }
71 }
72
73 pub fn generate(&mut self) -> u32 {
94 let otp = self.generate_at(self.counter);
95 self.counter += 1;
96 otp
97 }
98
99 pub fn generate_at(&self, counter: u64) -> u32 {
121 let message = counter.to_be_bytes();
122
123 let hmac_result = hmac(self.alg, self.secret.as_bytes(), &message);
124
125 let offset = (hmac_result[hmac_result.len() - 1] & 0x0f) as usize;
126
127 let code = ((u32::from(hmac_result[offset]) & 0x7f) << 24)
128 | (u32::from(hmac_result[offset + 1]) << 16)
129 | (u32::from(hmac_result[offset + 2]) << 8)
130 | u32::from(hmac_result[offset + 3]);
131
132 code % 10_u32.pow(self.digits as u32)
133 }
134
135 pub fn verify(&self, otp: u32, counter: u64, window: u64) -> bool {
161 if self.generate_at(counter) == otp {
162 return true;
163 }
164
165 for i in 1..=window {
166 if counter >= i && self.generate_at(counter - i) == otp {
167 return true;
168 }
169 if self.generate_at(counter + i) == otp {
170 return true;
171 }
172 }
173
174 false
175 }
176
177 pub fn to_uri(&self) -> String {
226 let secret = self.secret.into_base32();
227 let label = if self.issuer().is_empty() {
228 encoding::url::encode(self.label().as_bytes())
229 } else {
230 encoding::url::encode(format!("{}:{}", &self.issuer(), &self.label()).as_bytes())
231 };
232 let issuer = if !self.issuer().is_empty() {
233 format!(
234 "&issuer={}",
235 encoding::url::encode(self.issuer().as_bytes())
236 )
237 } else {
238 String::new()
239 };
240 let digits = self.digits;
241 let counter = self.counter;
242 let alg = self.alg.to_string();
243
244 format!(
245 "otpauth://hotp/{label}?secret={secret}{issuer}&algorithm={alg}&digits={digits}&counter={counter}"
246 )
247 }
248
249 #[inline]
250 pub fn alg(&self) -> Algorithm {
251 self.alg
252 }
253
254 #[inline]
255 pub fn issuer(&self) -> &str {
256 &self.issuer
257 }
258
259 #[inline]
260 pub fn label(&self) -> &str {
261 &self.label
262 }
263
264 #[inline]
265 pub fn digits(&self) -> u8 {
266 self.digits
267 }
268
269 #[inline]
270 pub fn counter(&self) -> u64 {
271 self.counter
272 }
273
274 #[inline]
275 pub fn secret(&self) -> &Secret {
276 &self.secret
277 }
278
279 pub fn from_uri(uri: &str) -> Result<Self, ParseUriError> {
318 let rest = uri
319 .strip_prefix("otpauth://hotp/")
320 .ok_or(ParseUriError::InvalidPrefix)?;
321
322 let (label_encoded, queries) = rest.split_once('?').ok_or(ParseUriError::InvalidFormat)?;
323 if label_encoded.is_empty() {
324 return Err(ParseUriError::InvalidLabel);
325 }
326
327 let label_decoded =
328 encoding::url::decode(label_encoded).map_err(|_| ParseUriError::InvalidLabel)?;
329
330 let (issuer_from_label, label) =
331 if let Some((issuer, label)) = label_decoded.split_once(':') {
332 (Some(issuer), label.to_string())
333 } else {
334 (None, label_decoded)
335 };
336
337 let params: std::collections::HashMap<&str, &str> = queries
338 .split('&')
339 .map(|param| match param.split_once('=') {
340 Some((key, val)) => (key, val),
341 None => (param, ""),
342 })
343 .collect();
344
345 let digits = params.get("digits").map_or(Ok(6), |val| {
346 val.parse::<u8>().map_err(|_| ParseUriError::InvalidDigits)
347 })?;
348
349 let counter = params
350 .get("counter")
351 .ok_or(ParseUriError::MissingCounter)
352 .and_then(|val| {
353 val.parse::<u64>()
354 .map_err(|_| ParseUriError::InvalidCounter)
355 })?;
356
357 let secret = params
358 .get("secret")
359 .ok_or(ParseUriError::MissingSecret)
360 .and_then(|raw_secret| {
361 Secret::from_base32(raw_secret).map_err(|_| ParseUriError::InvalidSecret)
362 })?;
363
364 let issuer_from_param = params
365 .get("issuer")
366 .map(|iss| encoding::url::decode(iss).map_err(|_| ParseUriError::InvalidIssuer))
367 .transpose()?;
368
369 let issuer = match (issuer_from_label, issuer_from_param) {
370 (None, None) => Ok(String::new()),
371 (None, Some(from_param)) => Ok(from_param),
372 (Some(from_label), None) => Ok(from_label.to_string()),
373 (Some(from_label), Some(from_param)) => {
374 if from_label != from_param {
375 Err(ParseUriError::IssuerMismatch)
376 } else {
377 Ok(from_param)
378 }
379 }
380 }?;
381
382 let alg = params
383 .get("algorithm")
384 .map(|alg| {
385 let alg = alg.to_uppercase();
386 match alg.as_str() {
387 "SHA1" => Ok(Algorithm::SHA1),
388 "SHA256" => Ok(Algorithm::SHA256),
389 "SHA512" => Ok(Algorithm::SHA512),
390 _ => Err(ParseUriError::InvalidAlgorithm),
391 }
392 })
393 .transpose()?;
394
395 Ok(Self::new(
396 alg.unwrap_or(Algorithm::SHA1),
397 issuer,
398 label,
399 digits,
400 counter,
401 secret,
402 ))
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum ParseUriError {
408 InvalidPrefix,
409 InvalidFormat,
410 InvalidLabel,
411 InvalidIssuer,
412 InvalidDigits,
413 InvalidCounter,
414 InvalidSecret,
415 InvalidAlgorithm,
416 IssuerMismatch,
417 MissingSecret,
418 MissingCounter,
419}
420
421impl std::fmt::Display for ParseUriError {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 match self {
424 ParseUriError::InvalidPrefix => {
425 f.write_str("URI must start with 'otpauth://hotp/'. Missing or incorrect prefix.")
426 }
427 ParseUriError::InvalidFormat => {
428 f.write_str("URI has an incorrect general format. Ensure it follows 'otpauth://type/label?parameters'.")
429 }
430 ParseUriError::InvalidLabel => {
431 f.write_str("The label (account name) in the URI is invalid or missing. Ensure it's properly encoded.")
432 }
433 ParseUriError::InvalidIssuer => {
434 f.write_str("The 'issuer' parameter is invalid or missing a value. Ensure it's present and correctly encoded.")
435 }
436 ParseUriError::InvalidDigits => {
437 f.write_str("The 'digits' parameter is invalid. It must be a positive integer, typically 6 or 8.")
438 }
439 ParseUriError::InvalidSecret => {
440 f.write_str("The 'secret' parameter is invalid or not properly base32 encoded.")
441 }
442 ParseUriError::InvalidAlgorithm => {
443 f.write_str("The 'algorithm' parameter is invalid. Expected 'SHA1', 'SHA256', or 'SHA512'.")
444 }
445 ParseUriError::IssuerMismatch => {
446 f.write_str("The issuer specified in the label does not match the 'issuer' parameter.")
447 }
448 ParseUriError::MissingSecret => {
449 f.write_str("The 'secret' parameter is required but missing from the URI.")
450 }
451 ParseUriError::InvalidCounter => {
452 f.write_str("The 'counter' parameter is invalid. It must be a positive integer.")
453 },
454 ParseUriError::MissingCounter => {
455 f.write_str("The 'counter' parameter is required but missing from the URI.")
456 },
457 }
458 }
459}
460
461impl std::error::Error for ParseUriError {}
462
463#[cfg(test)]
464impl Eq for Hotp {}
465
466#[cfg(test)]
467impl PartialEq for Hotp {
468 fn eq(&self, other: &Self) -> bool {
469 self.alg == other.alg
470 && self.issuer == other.issuer
471 && self.label == other.label
472 && self.digits == other.digits
473 && self.secret == other.secret
474 }
475}
476
477#[cfg(test)]
478impl std::fmt::Debug for Hotp {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 f.debug_struct("Hotp")
481 .field("alg", &self.alg.to_string())
482 .field("issuer", &self.issuer)
483 .field("label", &self.label)
484 .field("digits", &self.digits)
485 .field("counter", &self.counter)
486 .field("secret", &self.secret)
487 .finish()
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn test_from_uri() {
497 let alg = Algorithm::SHA512;
498 let issuer = String::from("example");
499 let label = String::from("alice@example.com");
500 let digits = 6;
501 let counter = 0;
502 let secret = Secret::from_bytes(b"The quick brown fox jumps over the lazy dog");
503
504 let hotp = Hotp::new(alg, issuer, label, digits, counter, secret);
505 let hotp_uri = hotp.to_uri();
506
507 let hotp_from_uri = Hotp::from_uri(&hotp_uri).expect("parse error");
508
509 assert_eq!(hotp_uri, hotp_from_uri.to_uri(), "should have same uri");
510 assert_eq!(hotp, hotp_from_uri, "should be equal");
511 }
512
513 #[test]
514 fn test_from_uri_with_invalid_prefix() {
515 let uri =
516 "otpauth://totp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024";
517 let result = Hotp::from_uri(uri);
518 assert!(
519 matches!(result, Err(ParseUriError::InvalidPrefix)),
520 "should be invalid prefix"
521 );
522 }
523
524 #[test]
525 fn test_from_uri_with_missing_counter() {
526 let uri =
527 "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024";
528 let result = Hotp::from_uri(uri);
529 assert!(
530 matches!(result, Err(ParseUriError::MissingCounter)),
531 "should be missing counter"
532 );
533 }
534
535 #[test]
536 fn test_from_uri_with_missing_secret() {
537 let uri = "otpauth://hotp/issuer:alice@example.com?algorithm=SHA1024&counter=69420";
538 let result = Hotp::from_uri(uri);
539 assert!(
540 matches!(result, Err(ParseUriError::MissingSecret)),
541 "should be missing secret"
542 );
543 }
544
545 #[test]
546 fn test_from_uri_with_invalid_algorithm() {
547 let uri = "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024&counter=69";
548 let result = Hotp::from_uri(uri);
549 assert!(
550 matches!(result, Err(ParseUriError::InvalidAlgorithm)),
551 "should be invalid algorithm"
552 );
553 }
554
555 #[test]
556 fn test_from_uri_with_invalid_uri_encoding() {
557 let uri = "otpauth://hotp/issuer%ZZ:alice@example.com?secret=JBSWY3DPEHPK3PXP";
558 let result = Hotp::from_uri(uri);
559 assert!(
560 matches!(result, Err(ParseUriError::InvalidLabel)),
561 "should be invalid label"
562 );
563 }
564
565 #[test]
566 fn test_from_uri_with_issuer_mismatch() {
567 let uri = "otpauth://hotp/javascript:alice@example.com?secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust";
568 let result = Hotp::from_uri(uri);
569 assert!(matches!(result, Err(ParseUriError::IssuerMismatch)));
570 }
571
572 #[test]
573 fn test_from_uri_with_invalid_format() {
574 let uri = "otpauth://hotp/javascript:alice@example.com&secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust";
575 let result = Hotp::from_uri(uri);
576 assert!(matches!(result, Err(ParseUriError::InvalidFormat)));
577 }
578}