1#![cfg_attr(docsrs, feature(doc_cfg))]
52
53mod custom_providers;
54mod rfc;
55mod secret;
56mod url_error;
57
58#[cfg(feature = "qr")]
59pub use qrcodegen_image;
60
61pub use rfc::{Rfc6238, Rfc6238Error};
62pub use secret::{Secret, SecretParseError};
63pub use url_error::TotpUrlError;
64
65use constant_time_eq::constant_time_eq;
66
67#[cfg(feature = "serde_support")]
68use serde::{Deserialize, Serialize};
69
70use core::fmt;
71
72#[cfg(feature = "otpauth")]
73use url::{Host, Url};
74
75use hmac::Mac;
76use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};
77
78type HmacSha1 = hmac::Hmac<sha1::Sha1>;
79type HmacSha256 = hmac::Hmac<sha2::Sha256>;
80type HmacSha512 = hmac::Hmac<sha2::Sha512>;
81
82#[cfg(feature = "steam")]
84const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY";
85
86#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
88#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
89pub enum Algorithm {
90 #[default]
91 SHA1,
92 SHA256,
93 SHA512,
94 #[cfg(feature = "steam")]
95 #[cfg_attr(docsrs, doc(cfg(feature = "steam")))]
96 Steam,
98}
99
100impl fmt::Display for Algorithm {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 Algorithm::SHA1 => f.write_str("SHA1"),
104 Algorithm::SHA256 => f.write_str("SHA256"),
105 Algorithm::SHA512 => f.write_str("SHA512"),
106 #[cfg(feature = "steam")]
107 Algorithm::Steam => f.write_str("SHA1"),
108 }
109 }
110}
111
112impl Algorithm {
113 fn hash<D>(mut digest: D, data: &[u8]) -> Vec<u8>
114 where
115 D: Mac,
116 {
117 digest.update(data);
118 digest.finalize().into_bytes().to_vec()
119 }
120
121 fn sign(&self, key: &[u8], data: &[u8]) -> Vec<u8> {
122 match self {
123 Algorithm::SHA1 => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data),
124 Algorithm::SHA256 => Algorithm::hash(HmacSha256::new_from_slice(key).unwrap(), data),
125 Algorithm::SHA512 => Algorithm::hash(HmacSha512::new_from_slice(key).unwrap(), data),
126 #[cfg(feature = "steam")]
127 Algorithm::Steam => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data),
128 }
129 }
130}
131
132fn system_time() -> Result<u64, SystemTimeError> {
133 let t = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
134 Ok(t)
135}
136
137#[derive(Debug, Clone)]
139#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
140#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
141pub struct TOTP {
142 #[cfg_attr(feature = "zeroize", zeroize(skip))]
144 pub algorithm: Algorithm,
145 pub digits: usize,
147 pub skew: u8,
149 pub step: u64,
151 pub secret: Vec<u8>,
155 #[cfg(feature = "otpauth")]
156 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
157 pub issuer: Option<String>,
161 #[cfg(feature = "otpauth")]
162 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
163 pub account_name: String,
166}
167
168impl PartialEq for TOTP {
169 fn eq(&self, other: &Self) -> bool {
172 if self.algorithm != other.algorithm {
173 return false;
174 }
175 if self.digits != other.digits {
176 return false;
177 }
178 if self.skew != other.skew {
179 return false;
180 }
181 if self.step != other.step {
182 return false;
183 }
184 constant_time_eq(self.secret.as_ref(), other.secret.as_ref())
185 }
186}
187
188#[cfg(feature = "otpauth")]
189impl core::fmt::Display for TOTP {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 write!(
192 f,
193 "digits: {}; step: {}; alg: {}; issuer: <{}>({})",
194 self.digits,
195 self.step,
196 self.algorithm,
197 self.issuer.clone().unwrap_or_else(|| "None".to_string()),
198 self.account_name
199 )
200 }
201}
202
203#[cfg(not(feature = "otpauth"))]
204impl core::fmt::Display for TOTP {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 write!(f, "digits: {}; step: {}; alg: {}", self.digits, self.step, self.algorithm,)
207 }
208}
209
210#[cfg(all(feature = "gen_secret", not(feature = "otpauth")))]
211#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))]
213impl Default for TOTP {
214 fn default() -> Self {
215 return TOTP::new(Algorithm::SHA1, 6, 1, 30, Secret::generate_secret().to_bytes().unwrap()).unwrap();
216 }
217}
218
219#[cfg(all(feature = "gen_secret", feature = "otpauth"))]
220#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))]
221impl Default for TOTP {
222 fn default() -> Self {
223 TOTP::new(
224 Algorithm::SHA1,
225 6,
226 1,
227 30,
228 Secret::generate_secret().to_bytes().unwrap(),
229 None,
230 "".to_string(),
231 )
232 .unwrap()
233 }
234}
235
236impl TOTP {
237 #[cfg(feature = "otpauth")]
238 pub fn new(
259 algorithm: Algorithm,
260 digits: usize,
261 skew: u8,
262 step: u64,
263 secret: Vec<u8>,
264 issuer: Option<String>,
265 account_name: String,
266 ) -> Result<TOTP, TotpUrlError> {
267 crate::rfc::assert_digits(&digits)?;
268 crate::rfc::assert_secret_length(secret.as_ref())?;
269 if issuer.is_some() && issuer.as_ref().unwrap().contains(':') {
270 return Err(TotpUrlError::Issuer(issuer.as_ref().unwrap().to_string()));
271 }
272 if account_name.contains(':') {
273 return Err(TotpUrlError::AccountName(account_name));
274 }
275 Ok(Self::new_unchecked(algorithm, digits, skew, step, secret, issuer, account_name))
276 }
277
278 #[cfg(feature = "otpauth")]
279 pub fn new_unchecked(
292 algorithm: Algorithm,
293 digits: usize,
294 skew: u8,
295 step: u64,
296 secret: Vec<u8>,
297 issuer: Option<String>,
298 account_name: String,
299 ) -> TOTP {
300 TOTP {
301 algorithm,
302 digits,
303 skew,
304 step,
305 secret,
306 issuer,
307 account_name,
308 }
309 }
310
311 #[cfg(not(feature = "otpauth"))]
312 pub fn new(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: Vec<u8>) -> Result<TOTP, TotpUrlError> {
331 crate::rfc::assert_digits(&digits)?;
332 crate::rfc::assert_secret_length(secret.as_ref())?;
333 Ok(Self::new_unchecked(algorithm, digits, skew, step, secret))
334 }
335
336 #[cfg(not(feature = "otpauth"))]
337 pub fn new_unchecked(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: Vec<u8>) -> TOTP {
350 TOTP {
351 algorithm,
352 digits,
353 skew,
354 step,
355 secret,
356 }
357 }
358
359 pub fn from_rfc6238(rfc: Rfc6238) -> Result<TOTP, TotpUrlError> {
365 TOTP::try_from(rfc)
366 }
367
368 pub fn sign(&self, time: u64) -> Vec<u8> {
370 self.algorithm.sign(self.secret.as_ref(), (time / self.step).to_be_bytes().as_ref())
371 }
372
373 pub fn generate(&self, time: u64) -> String {
375 let result: &[u8] = &self.sign(time);
376 let offset = (result.last().unwrap() & 15) as usize;
377 #[allow(unused_mut)]
378 let mut result = u32::from_be_bytes(result[offset..offset + 4].try_into().unwrap()) & 0x7fff_ffff;
379
380 match self.algorithm {
381 Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => {
382 format!("{1:00$}", self.digits, result % 10_u32.pow(self.digits as u32))
383 }
384 #[cfg(feature = "steam")]
385 Algorithm::Steam => (0..self.digits)
386 .map(|_| {
387 let c = STEAM_CHARS.chars().nth(result as usize % STEAM_CHARS.len()).unwrap();
388 result /= STEAM_CHARS.len() as u32;
389 c
390 })
391 .collect(),
392 }
393 }
394
395 pub fn next_step(&self, time: u64) -> u64 {
398 let step = time / self.step;
399
400 (step + 1) * self.step
401 }
402
403 pub fn next_step_current(&self) -> Result<u64, SystemTimeError> {
406 let t = system_time()?;
407 Ok(self.next_step(t))
408 }
409
410 pub fn ttl(&self) -> Result<u64, SystemTimeError> {
412 let t = system_time()?;
413 Ok(self.step - (t % self.step))
414 }
415
416 pub fn generate_current(&self) -> Result<String, SystemTimeError> {
418 let t = system_time()?;
419 Ok(self.generate(t))
420 }
421
422 pub fn check(&self, token: &str, time: u64) -> bool {
424 let basestep = time / self.step - (self.skew as u64);
425 for i in 0..(self.skew as u16) * 2 + 1 {
426 let step_time = (basestep + (i as u64)) * self.step;
427
428 if constant_time_eq(self.generate(step_time).as_bytes(), token.as_bytes()) {
429 return true;
430 }
431 }
432 false
433 }
434
435 pub fn check_current(&self, token: &str) -> Result<bool, SystemTimeError> {
437 let t = system_time()?;
438 Ok(self.check(token, t))
439 }
440
441 pub fn get_secret_base32(&self) -> String {
443 base32::encode(base32::Alphabet::RFC4648 { padding: false }, self.secret.as_ref())
444 }
445
446 #[cfg(feature = "otpauth")]
448 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
449 pub fn from_url<S: AsRef<str>>(url: S) -> Result<TOTP, TotpUrlError> {
450 let (algorithm, digits, skew, step, secret, issuer, account_name) = Self::parts_from_url(url)?;
451 TOTP::new(algorithm, digits, skew, step, secret, issuer, account_name)
452 }
453
454 #[cfg(feature = "otpauth")]
456 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
457 pub fn from_url_unchecked<S: AsRef<str>>(url: S) -> Result<TOTP, TotpUrlError> {
458 let (algorithm, digits, skew, step, secret, issuer, account_name) = Self::parts_from_url(url)?;
459 Ok(TOTP::new_unchecked(algorithm, digits, skew, step, secret, issuer, account_name))
460 }
461
462 #[cfg(feature = "otpauth")]
464 fn parts_from_url<S: AsRef<str>>(url: S) -> Result<(Algorithm, usize, u8, u64, Vec<u8>, Option<String>, String), TotpUrlError> {
465 let mut algorithm = Algorithm::SHA1;
466 let mut digits = 6;
467 let mut step = 30;
468 let mut secret = Vec::new();
469 let mut issuer: Option<String> = None;
470 let mut account_name: String;
471
472 let url = Url::parse(url.as_ref()).map_err(TotpUrlError::Url)?;
473 if url.scheme() != "otpauth" {
474 return Err(TotpUrlError::Scheme(url.scheme().to_string()));
475 }
476 match url.host() {
477 Some(Host::Domain("totp")) => {}
478 #[cfg(feature = "steam")]
479 Some(Host::Domain("steam")) => {
480 algorithm = Algorithm::Steam;
481 }
482 _ => {
483 return Err(TotpUrlError::Host(url.host().unwrap().to_string()));
484 }
485 }
486
487 let path = url.path().trim_start_matches('/');
488 let path = urlencoding::decode(path)
489 .map_err(|_| TotpUrlError::AccountNameDecoding(path.to_string()))?
490 .to_string();
491 if path.contains(':') {
492 let parts = path.split_once(':').unwrap();
493 issuer = Some(parts.0.to_owned());
494 account_name = parts.1.to_owned();
495 } else {
496 account_name = path;
497 }
498
499 account_name = urlencoding::decode(account_name.as_str())
500 .map_err(|_| TotpUrlError::AccountName(account_name.to_string()))?
501 .to_string();
502
503 for (key, value) in url.query_pairs() {
504 match key.as_ref() {
505 #[cfg(feature = "steam")]
506 "algorithm" if algorithm == Algorithm::Steam => {
507 }
509 "algorithm" => {
510 algorithm = match value.as_ref() {
511 "SHA1" => Algorithm::SHA1,
512 "SHA256" => Algorithm::SHA256,
513 "SHA512" => Algorithm::SHA512,
514 _ => return Err(TotpUrlError::Algorithm(value.to_string())),
515 }
516 }
517 "digits" => {
518 digits = value.parse::<usize>().map_err(|_| TotpUrlError::Digits(value.to_string()))?;
519 }
520 "period" => {
521 step = value.parse::<u64>().map_err(|_| TotpUrlError::Step(value.to_string()))?;
522 }
523 "secret" => {
524 secret = base32::decode(base32::Alphabet::RFC4648 { padding: false }, value.as_ref())
525 .ok_or_else(|| TotpUrlError::Secret(value.to_string()))?;
526 }
527 #[cfg(feature = "steam")]
528 "issuer" if value.to_lowercase() == "steam" => {
529 algorithm = Algorithm::Steam;
530 digits = 5;
531 issuer = Some(value.into());
532 }
533 "issuer" => {
534 let param_issuer: String = value.into();
535 if issuer.is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() {
536 return Err(TotpUrlError::IssuerMistmatch(issuer.as_ref().unwrap().to_string(), param_issuer));
537 }
538 issuer = Some(param_issuer);
539 #[cfg(feature = "steam")]
540 if issuer == Some("Steam".into()) {
541 algorithm = Algorithm::Steam;
542 }
543 }
544 _ => {}
545 }
546 }
547
548 #[cfg(feature = "steam")]
549 if algorithm == Algorithm::Steam {
550 digits = 5;
551 step = 30;
552 issuer = Some("Steam".into());
553 }
554
555 if secret.is_empty() {
556 return Err(TotpUrlError::Secret("".to_string()));
557 }
558
559 Ok((algorithm, digits, 1, step, secret, issuer, account_name))
560 }
561
562 #[cfg(feature = "otpauth")]
567 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
568 pub fn get_url(&self) -> String {
569 #[allow(unused_mut)]
570 let mut host = "totp";
571 #[cfg(feature = "steam")]
572 if self.algorithm == Algorithm::Steam {
573 host = "steam";
574 }
575 let account_name = urlencoding::encode(self.account_name.as_str()).to_string();
576 let mut params = vec![format!("secret={}", self.get_secret_base32())];
577 if self.digits != 6 {
578 params.push(format!("digits={}", self.digits));
579 }
580 if self.algorithm != Algorithm::SHA1 {
581 params.push(format!("algorithm={}", self.algorithm));
582 }
583 let label = if let Some(issuer) = &self.issuer {
584 let issuer = urlencoding::encode(issuer);
585 params.push(format!("issuer={}", issuer));
586 format!("{}:{}", issuer, account_name)
587 } else {
588 account_name
589 };
590 if self.step != 30 {
591 params.push(format!("period={}", self.step));
592 }
593
594 format!("otpauth://{}/{}?{}", host, label, params.join("&"))
595 }
596}
597
598#[cfg(feature = "qr")]
599#[cfg_attr(docsrs, doc(cfg(feature = "qr")))]
600impl TOTP {
601 #[deprecated(
602 since = "5.3.0",
603 note = "get_qr was forcing the use of png as a base64. Use get_qr_base64 or get_qr_png instead. Will disappear in 6.0."
604 )]
605 pub fn get_qr(&self) -> Result<String, String> {
606 let url = self.get_url();
607 qrcodegen_image::draw_base64(&url)
608 }
609
610 pub fn get_qr_base64(&self) -> Result<String, String> {
623 let url = self.get_url();
624 qrcodegen_image::draw_base64(&url)
625 }
626
627 pub fn get_qr_png(&self) -> Result<Vec<u8>, String> {
639 let url = self.get_url();
640 qrcodegen_image::draw_png(&url)
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 #[cfg(feature = "gen_secret")]
650 fn default_values() {
651 let totp = TOTP::default();
652 assert_eq!(totp.algorithm, Algorithm::SHA1);
653 assert_eq!(totp.digits, 6);
654 assert_eq!(totp.skew, 1);
655 assert_eq!(totp.step, 30)
656 }
657
658 #[test]
659 #[cfg(feature = "otpauth")]
660 fn new_wrong_issuer() {
661 let totp = TOTP::new(
662 Algorithm::SHA1,
663 6,
664 1,
665 1,
666 "TestSecretSuperSecret".as_bytes().to_vec(),
667 Some("Github:".to_string()),
668 "constantoine@github.com".to_string(),
669 );
670 assert!(totp.is_err());
671 assert!(matches!(totp.unwrap_err(), TotpUrlError::Issuer(_)));
672 }
673
674 #[test]
675 #[cfg(feature = "otpauth")]
676 fn new_wrong_account_name() {
677 let totp = TOTP::new(
678 Algorithm::SHA1,
679 6,
680 1,
681 1,
682 "TestSecretSuperSecret".as_bytes().to_vec(),
683 Some("Github".to_string()),
684 "constantoine:github.com".to_string(),
685 );
686 assert!(totp.is_err());
687 assert!(matches!(totp.unwrap_err(), TotpUrlError::AccountName(_)));
688 }
689
690 #[test]
691 #[cfg(feature = "otpauth")]
692 fn new_wrong_account_name_no_issuer() {
693 let totp = TOTP::new(
694 Algorithm::SHA1,
695 6,
696 1,
697 1,
698 "TestSecretSuperSecret".as_bytes().to_vec(),
699 None,
700 "constantoine:github.com".to_string(),
701 );
702 assert!(totp.is_err());
703 assert!(matches!(totp.unwrap_err(), TotpUrlError::AccountName(_)));
704 }
705
706 #[test]
707 #[cfg(feature = "otpauth")]
708 fn comparison_ok() {
709 let reference = TOTP::new(
710 Algorithm::SHA1,
711 6,
712 1,
713 1,
714 "TestSecretSuperSecret".as_bytes().to_vec(),
715 Some("Github".to_string()),
716 "constantoine@github.com".to_string(),
717 )
718 .unwrap();
719 let test = TOTP::new(
720 Algorithm::SHA1,
721 6,
722 1,
723 1,
724 "TestSecretSuperSecret".as_bytes().to_vec(),
725 Some("Github".to_string()),
726 "constantoine@github.com".to_string(),
727 )
728 .unwrap();
729 assert_eq!(reference, test);
730 }
731
732 #[test]
733 #[cfg(not(feature = "otpauth"))]
734 fn comparison_different_algo() {
735 let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
736 let test = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
737 assert_ne!(reference, test);
738 }
739
740 #[test]
741 #[cfg(not(feature = "otpauth"))]
742 fn comparison_different_digits() {
743 let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
744 let test = TOTP::new(Algorithm::SHA1, 8, 1, 1, "TestSecretSuperSecret".into()).unwrap();
745 assert_ne!(reference, test);
746 }
747
748 #[test]
749 #[cfg(not(feature = "otpauth"))]
750 fn comparison_different_skew() {
751 let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
752 let test = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
753 assert_ne!(reference, test);
754 }
755
756 #[test]
757 #[cfg(not(feature = "otpauth"))]
758 fn comparison_different_step() {
759 let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
760 let test = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
761 assert_ne!(reference, test);
762 }
763
764 #[test]
765 #[cfg(not(feature = "otpauth"))]
766 fn comparison_different_secret() {
767 let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
768 let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretDifferentSecret".into()).unwrap();
769 assert_ne!(reference, test);
770 }
771
772 #[test]
773 #[cfg(feature = "otpauth")]
774 fn url_for_secret_matches_sha1_without_issuer() {
775 let totp = TOTP::new(
776 Algorithm::SHA1,
777 6,
778 1,
779 30,
780 "TestSecretSuperSecret".as_bytes().to_vec(),
781 None,
782 "constantoine@github.com".to_string(),
783 )
784 .unwrap();
785 let url = totp.get_url();
786 assert_eq!(
787 url.as_str(),
788 "otpauth://totp/constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
789 );
790 }
791
792 #[test]
793 #[cfg(feature = "otpauth")]
794 fn url_for_secret_matches_sha1() {
795 let totp = TOTP::new(
796 Algorithm::SHA1,
797 6,
798 1,
799 30,
800 "TestSecretSuperSecret".as_bytes().to_vec(),
801 Some("Github".to_string()),
802 "constantoine@github.com".to_string(),
803 )
804 .unwrap();
805 let url = totp.get_url();
806 assert_eq!(
807 url.as_str(),
808 "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github"
809 );
810 }
811
812 #[test]
813 #[cfg(feature = "otpauth")]
814 fn url_for_secret_matches_sha256() {
815 let totp = TOTP::new(
816 Algorithm::SHA256,
817 6,
818 1,
819 30,
820 "TestSecretSuperSecret".as_bytes().to_vec(),
821 Some("Github".to_string()),
822 "constantoine@github.com".to_string(),
823 )
824 .unwrap();
825 let url = totp.get_url();
826 assert_eq!(
827 url.as_str(),
828 "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github"
829 );
830 }
831
832 #[test]
833 #[cfg(feature = "otpauth")]
834 fn url_for_secret_matches_sha512() {
835 let totp = TOTP::new(
836 Algorithm::SHA512,
837 6,
838 1,
839 30,
840 "TestSecretSuperSecret".as_bytes().to_vec(),
841 Some("Github".to_string()),
842 "constantoine@github.com".to_string(),
843 )
844 .unwrap();
845 let url = totp.get_url();
846 assert_eq!(
847 url.as_str(),
848 "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github"
849 );
850 }
851
852 #[test]
853 #[cfg(all(feature = "otpauth", feature = "gen_secret"))]
854 fn ttl() {
855 let secret = Secret::default();
856 let totp_rfc = Rfc6238::with_defaults(secret.to_bytes().unwrap()).unwrap();
857 let totp = TOTP::from_rfc6238(totp_rfc);
858 assert!(totp.is_ok());
859 }
860
861 #[test]
862 #[cfg(feature = "otpauth")]
863 fn ttl_ok() {
864 let totp = TOTP::new(
865 Algorithm::SHA512,
866 6,
867 1,
868 1,
869 "TestSecretSuperSecret".as_bytes().to_vec(),
870 Some("Github".to_string()),
871 "constantoine@github.com".to_string(),
872 )
873 .unwrap();
874 assert!(totp.ttl().is_ok());
875 }
876
877 #[test]
878 #[cfg(not(feature = "otpauth"))]
879 fn returns_base32() {
880 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
881 assert_eq!(totp.get_secret_base32().as_str(), "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ");
882 }
883
884 #[test]
885 #[cfg(not(feature = "otpauth"))]
886 fn generate_token() {
887 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
888 assert_eq!(totp.generate(1000).as_str(), "659761");
889 }
890
891 #[test]
892 #[cfg(not(feature = "otpauth"))]
893 fn generate_token_current() {
894 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
895 let time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
896 assert_eq!(totp.generate(time).as_str(), totp.generate_current().unwrap());
897 }
898
899 #[test]
900 #[cfg(not(feature = "otpauth"))]
901 fn generates_token_sha256() {
902 let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
903 assert_eq!(totp.generate(1000).as_str(), "076417");
904 }
905
906 #[test]
907 #[cfg(not(feature = "otpauth"))]
908 fn generates_token_sha512() {
909 let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
910 assert_eq!(totp.generate(1000).as_str(), "473536");
911 }
912
913 #[test]
914 #[cfg(not(feature = "otpauth"))]
915 fn checks_token() {
916 let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
917 assert!(totp.check("659761", 1000));
918 }
919
920 #[test]
921 #[cfg(not(feature = "otpauth"))]
922 fn checks_token_big_skew() {
923 let totp = TOTP::new(Algorithm::SHA1, 6, 255, 1, "TestSecretSuperSecret".into()).unwrap();
924 assert!(totp.check("659761", 1000));
925 }
926
927 #[test]
928 #[cfg(not(feature = "otpauth"))]
929 fn checks_token_current() {
930 let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
931 assert!(totp.check_current(&totp.generate_current().unwrap()).unwrap());
932 assert!(!totp.check_current("bogus").unwrap());
933 }
934
935 #[test]
936 #[cfg(not(feature = "otpauth"))]
937 fn checks_token_with_skew() {
938 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
939 assert!(totp.check("174269", 1000) && totp.check("659761", 1000) && totp.check("260393", 1000));
940 }
941
942 #[test]
943 #[cfg(not(feature = "otpauth"))]
944 fn next_step() {
945 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
946 assert!(totp.next_step(0) == 30);
947 assert!(totp.next_step(29) == 30);
948 assert!(totp.next_step(30) == 60);
949 }
950
951 #[test]
952 #[cfg(not(feature = "otpauth"))]
953 fn next_step_current() {
954 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
955 let t = system_time().unwrap();
956 assert!(totp.next_step_current().unwrap() == totp.next_step(t));
957 }
958
959 #[test]
960 #[cfg(feature = "otpauth")]
961 fn from_url_err() {
962 assert!(TOTP::from_url("otpauth://hotp/123").is_err());
963 assert!(TOTP::from_url("otpauth://totp/GitHub:test").is_err());
964 assert!(TOTP::from_url("otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256").is_err());
965 assert!(
966 TOTP::from_url(
967 "otpauth://totp/Github:constantoine%40github.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1"
968 )
969 .is_err()
970 )
971 }
972
973 #[test]
974 #[cfg(feature = "otpauth")]
975 fn from_url_default() {
976 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap();
977 assert_eq!(
978 totp.secret,
979 base32::decode(base32::Alphabet::RFC4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap()
980 );
981 assert_eq!(totp.algorithm, Algorithm::SHA1);
982 assert_eq!(totp.digits, 6);
983 assert_eq!(totp.skew, 1);
984 assert_eq!(totp.step, 30);
985 }
986
987 #[test]
988 #[cfg(feature = "otpauth")]
989 fn from_url_query() {
990 let totp =
991 TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
992 assert_eq!(
993 totp.secret,
994 base32::decode(base32::Alphabet::RFC4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap()
995 );
996 assert_eq!(totp.algorithm, Algorithm::SHA256);
997 assert_eq!(totp.digits, 8);
998 assert_eq!(totp.skew, 1);
999 assert_eq!(totp.step, 60);
1000 }
1001
1002 #[test]
1003 #[cfg(feature = "otpauth")]
1004 fn from_url_query_sha512() {
1005 let totp =
1006 TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA512").unwrap();
1007 assert_eq!(
1008 totp.secret,
1009 base32::decode(base32::Alphabet::RFC4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap()
1010 );
1011 assert_eq!(totp.algorithm, Algorithm::SHA512);
1012 assert_eq!(totp.digits, 8);
1013 assert_eq!(totp.skew, 1);
1014 assert_eq!(totp.step, 60);
1015 }
1016
1017 #[test]
1018 #[cfg(feature = "otpauth")]
1019 fn from_url_to_url() {
1020 let totp = TOTP::from_url(
1021 "otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1",
1022 )
1023 .unwrap();
1024 let totp_bis = TOTP::new(
1025 Algorithm::SHA1,
1026 6,
1027 1,
1028 30,
1029 "TestSecretSuperSecret".as_bytes().to_vec(),
1030 Some("Github".to_string()),
1031 "constantoine@github.com".to_string(),
1032 )
1033 .unwrap();
1034 assert_eq!(totp.get_url(), totp_bis.get_url());
1035 }
1036
1037 #[test]
1038 #[cfg(feature = "otpauth")]
1039 fn from_url_unknown_param() {
1040 let totp =
1041 TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256&foo=bar")
1042 .unwrap();
1043 assert_eq!(
1044 totp.secret,
1045 base32::decode(base32::Alphabet::RFC4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap()
1046 );
1047 assert_eq!(totp.algorithm, Algorithm::SHA256);
1048 assert_eq!(totp.digits, 8);
1049 assert_eq!(totp.skew, 1);
1050 assert_eq!(totp.step, 60);
1051 }
1052
1053 #[test]
1054 #[cfg(feature = "otpauth")]
1055 fn from_url_issuer_special() {
1056 let totp = TOTP::from_url("otpauth://totp/Github%40:constantoine%40github.com?issuer=Github%40&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
1057 let totp_bis = TOTP::new(
1058 Algorithm::SHA1,
1059 6,
1060 1,
1061 30,
1062 "TestSecretSuperSecret".as_bytes().to_vec(),
1063 Some("Github@".to_string()),
1064 "constantoine@github.com".to_string(),
1065 )
1066 .unwrap();
1067 assert_eq!(totp.get_url(), totp_bis.get_url());
1068 assert_eq!(totp.issuer.as_ref().unwrap(), "Github@");
1069 }
1070
1071 #[test]
1072 #[cfg(feature = "otpauth")]
1073 fn from_url_account_name_issuer() {
1074 let totp =
1075 TOTP::from_url("otpauth://totp/Github:constantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1")
1076 .unwrap();
1077 let totp_bis = TOTP::new(
1078 Algorithm::SHA1,
1079 6,
1080 1,
1081 30,
1082 "TestSecretSuperSecret".as_bytes().to_vec(),
1083 Some("Github".to_string()),
1084 "constantoine".to_string(),
1085 )
1086 .unwrap();
1087 assert_eq!(totp.get_url(), totp_bis.get_url());
1088 assert_eq!(totp.account_name, "constantoine");
1089 assert_eq!(totp.issuer.as_ref().unwrap(), "Github");
1090 }
1091
1092 #[test]
1093 #[cfg(feature = "otpauth")]
1094 fn from_url_account_name_issuer_encoded() {
1095 let totp = TOTP::from_url(
1096 "otpauth://totp/Github%3Aconstantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1",
1097 )
1098 .unwrap();
1099 let totp_bis = TOTP::new(
1100 Algorithm::SHA1,
1101 6,
1102 1,
1103 30,
1104 "TestSecretSuperSecret".as_bytes().to_vec(),
1105 Some("Github".to_string()),
1106 "constantoine".to_string(),
1107 )
1108 .unwrap();
1109 assert_eq!(totp.get_url(), totp_bis.get_url());
1110 assert_eq!(totp.account_name, "constantoine");
1111 assert_eq!(totp.issuer.as_ref().unwrap(), "Github");
1112 }
1113
1114 #[test]
1115 #[cfg(feature = "otpauth")]
1116 fn from_url_query_issuer() {
1117 let totp = TOTP::from_url(
1118 "otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256",
1119 )
1120 .unwrap();
1121 assert_eq!(
1122 totp.secret,
1123 base32::decode(base32::Alphabet::RFC4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").unwrap()
1124 );
1125 assert_eq!(totp.algorithm, Algorithm::SHA256);
1126 assert_eq!(totp.digits, 8);
1127 assert_eq!(totp.skew, 1);
1128 assert_eq!(totp.step, 60);
1129 assert_eq!(totp.issuer.as_ref().unwrap(), "GitHub");
1130 }
1131
1132 #[test]
1133 #[cfg(feature = "otpauth")]
1134 fn from_url_wrong_scheme() {
1135 let totp =
1136 TOTP::from_url("http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256");
1137 assert!(totp.is_err());
1138 let err = totp.unwrap_err();
1139 assert!(matches!(err, TotpUrlError::Scheme(_)));
1140 }
1141
1142 #[test]
1143 #[cfg(feature = "otpauth")]
1144 fn from_url_wrong_algo() {
1145 let totp =
1146 TOTP::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5");
1147 assert!(totp.is_err());
1148 let err = totp.unwrap_err();
1149 assert!(matches!(err, TotpUrlError::Algorithm(_)));
1150 }
1151
1152 #[test]
1153 #[cfg(feature = "otpauth")]
1154 fn from_url_query_different_issuers() {
1155 let totp = TOTP::from_url(
1156 "otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256",
1157 );
1158 assert!(totp.is_err());
1159 assert!(matches!(totp.unwrap_err(), TotpUrlError::IssuerMistmatch(_, _)));
1160 }
1161
1162 #[test]
1163 #[cfg(feature = "qr")]
1164 fn generates_qr() {
1165 use qrcodegen_image::qrcodegen;
1166 use sha2::{Digest, Sha512};
1167
1168 let totp = TOTP::new(
1169 Algorithm::SHA1,
1170 6,
1171 1,
1172 30,
1173 "TestSecretSuperSecret".as_bytes().to_vec(),
1174 Some("Github".to_string()),
1175 "constantoine@github.com".to_string(),
1176 )
1177 .unwrap();
1178 let url = totp.get_url();
1179 let qr = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium).expect("could not generate qr");
1180 let data = qrcodegen_image::draw_canvas(qr).into_raw();
1181
1182 let hash_digest = Sha512::digest(data);
1184 assert_eq!(
1185 format!("{:x}", hash_digest).as_str(),
1186 "fbb0804f1e4f4c689d22292c52b95f0783b01b4319973c0c50dd28af23dbbbe663dce4eb05a7959086d9092341cb9f103ec5a9af4a973867944e34c063145328"
1187 );
1188 }
1189
1190 #[test]
1191 #[cfg(feature = "qr")]
1192 fn generates_qr_base64_ok() {
1193 let totp = TOTP::new(
1194 Algorithm::SHA1,
1195 6,
1196 1,
1197 1,
1198 "TestSecretSuperSecret".as_bytes().to_vec(),
1199 Some("Github".to_string()),
1200 "constantoine@github.com".to_string(),
1201 )
1202 .unwrap();
1203 let qr = totp.get_qr_base64();
1204 assert!(qr.is_ok());
1205 }
1206
1207 #[test]
1208 #[cfg(feature = "qr")]
1209 fn generates_qr_png_ok() {
1210 let totp = TOTP::new(
1211 Algorithm::SHA1,
1212 6,
1213 1,
1214 1,
1215 "TestSecretSuperSecret".as_bytes().to_vec(),
1216 Some("Github".to_string()),
1217 "constantoine@github.com".to_string(),
1218 )
1219 .unwrap();
1220 let qr = totp.get_qr_png();
1221 assert!(qr.is_ok());
1222 }
1223}