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
70#[cfg(feature = "zeroize")]
71use zeroize;
72
73use core::fmt;
74
75#[cfg(feature = "otpauth")]
76use url::{Host, Url};
77
78use hmac::Mac;
79use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};
80
81type HmacSha1 = hmac::Hmac<sha1::Sha1>;
82type HmacSha256 = hmac::Hmac<sha2::Sha256>;
83type HmacSha512 = hmac::Hmac<sha2::Sha512>;
84
85#[cfg(feature = "steam")]
87const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY";
88
89#[derive(Debug, Copy, Clone, Eq, PartialEq)]
91#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
92pub enum Algorithm {
93 SHA1,
96 SHA256,
99 SHA512,
102 #[cfg(feature = "steam")]
103 #[cfg_attr(docsrs, doc(cfg(feature = "steam")))]
104 Steam,
106}
107
108impl Default for Algorithm {
109 fn default() -> Self {
110 Algorithm::SHA1
111 }
112}
113
114impl fmt::Display for Algorithm {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Algorithm::SHA1 => f.write_str("SHA1"),
118 Algorithm::SHA256 => f.write_str("SHA256"),
119 Algorithm::SHA512 => f.write_str("SHA512"),
120 #[cfg(feature = "steam")]
121 Algorithm::Steam => f.write_str("SHA1"),
122 }
123 }
124}
125
126impl Algorithm {
127 fn hash<D>(mut digest: D, data: &[u8]) -> Vec<u8>
128 where
129 D: Mac,
130 {
131 digest.update(data);
132 digest.finalize().into_bytes().to_vec()
133 }
134
135 fn sign(&self, key: &[u8], data: &[u8]) -> Vec<u8> {
136 match self {
137 Algorithm::SHA1 => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data),
138 Algorithm::SHA256 => Algorithm::hash(HmacSha256::new_from_slice(key).unwrap(), data),
139 Algorithm::SHA512 => Algorithm::hash(HmacSha512::new_from_slice(key).unwrap(), data),
140 #[cfg(feature = "steam")]
141 Algorithm::Steam => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data),
142 }
143 }
144}
145
146fn system_time() -> Result<u64, SystemTimeError> {
147 let t = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
148 Ok(t)
149}
150
151#[derive(Debug, Clone)]
153#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
154#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
155pub struct TOTP {
156 #[cfg_attr(feature = "zeroize", zeroize(skip))]
158 pub algorithm: Algorithm,
159 pub digits: usize,
161 pub skew: u8,
163 pub step: u64,
165 pub secret: Vec<u8>,
169 #[cfg(feature = "otpauth")]
170 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
171 pub issuer: Option<String>,
175 #[cfg(feature = "otpauth")]
176 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
177 pub account_name: String,
180}
181
182impl PartialEq for TOTP {
183 fn eq(&self, other: &Self) -> bool {
186 if self.algorithm != other.algorithm {
187 return false;
188 }
189 if self.digits != other.digits {
190 return false;
191 }
192 if self.skew != other.skew {
193 return false;
194 }
195 if self.step != other.step {
196 return false;
197 }
198 constant_time_eq(self.secret.as_ref(), other.secret.as_ref())
199 }
200}
201
202#[cfg(feature = "otpauth")]
203impl core::fmt::Display for TOTP {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 write!(
206 f,
207 "digits: {}; step: {}; alg: {}; issuer: <{}>({})",
208 self.digits,
209 self.step,
210 self.algorithm,
211 self.issuer.clone().unwrap_or_else(|| "None".to_string()),
212 self.account_name
213 )
214 }
215}
216
217#[cfg(not(feature = "otpauth"))]
218impl core::fmt::Display for TOTP {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 write!(
221 f,
222 "digits: {}; step: {}; alg: {}",
223 self.digits, self.step, self.algorithm,
224 )
225 }
226}
227
228#[cfg(all(feature = "gen_secret", not(feature = "otpauth")))]
229#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))]
231impl Default for TOTP {
232 fn default() -> Self {
233 return TOTP::new(
234 Algorithm::SHA1,
235 6,
236 1,
237 30,
238 Secret::generate_secret().to_bytes().unwrap(),
239 )
240 .unwrap();
241 }
242}
243
244#[cfg(all(feature = "gen_secret", feature = "otpauth"))]
245#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))]
246impl Default for TOTP {
247 fn default() -> Self {
248 TOTP::new(
249 Algorithm::SHA1,
250 6,
251 1,
252 30,
253 Secret::generate_secret().to_bytes().unwrap(),
254 None,
255 "".to_string(),
256 )
257 .unwrap()
258 }
259}
260
261impl TOTP {
262 #[cfg(feature = "otpauth")]
263 #[allow(unused_mut)]
284 pub fn new(
285 algorithm: Algorithm,
286 digits: usize,
287 skew: u8,
288 step: u64,
289 mut secret: Vec<u8>,
290 issuer: Option<String>,
291 account_name: String,
292 ) -> Result<TOTP, TotpUrlError> {
293 let validate = || {
294 crate::rfc::assert_digits(&digits)?;
295 crate::rfc::assert_secret_length(secret.as_ref())?;
296
297 if issuer.is_some() && issuer.as_ref().unwrap().contains(':') {
298 return Err(TotpUrlError::Issuer(issuer.as_ref().unwrap().to_string()));
299 }
300
301 if account_name.contains(':') {
302 return Err(TotpUrlError::AccountName(account_name.clone()));
303 }
304
305 Ok(())
306 };
307
308 if let Err(e) = validate() {
309 #[cfg(feature = "zeroize")]
310 zeroize::Zeroize::zeroize(&mut secret);
311
312 return Err(e);
313 }
314
315 Ok(Self::new_unchecked(
316 algorithm,
317 digits,
318 skew,
319 step,
320 secret,
321 issuer,
322 account_name,
323 ))
324 }
325
326 #[cfg(feature = "otpauth")]
327 pub fn new_unchecked(
340 algorithm: Algorithm,
341 digits: usize,
342 skew: u8,
343 step: u64,
344 secret: Vec<u8>,
345 issuer: Option<String>,
346 account_name: String,
347 ) -> TOTP {
348 TOTP {
349 algorithm,
350 digits,
351 skew,
352 step,
353 secret,
354 issuer,
355 account_name,
356 }
357 }
358
359 #[cfg(not(feature = "otpauth"))]
360 #[allow(unused_mut)]
379 pub fn new(
380 algorithm: Algorithm,
381 digits: usize,
382 skew: u8,
383 step: u64,
384 mut secret: Vec<u8>,
385 ) -> Result<TOTP, TotpUrlError> {
386 let validate = || {
387 crate::rfc::assert_digits(&digits)?;
388 crate::rfc::assert_secret_length(secret.as_ref())?;
389
390 Ok(())
391 };
392
393 if let Err(e) = validate() {
394 #[cfg(feature = "zeroize")]
395 zeroize::Zeroize::zeroize(&mut secret);
396
397 return Err(e);
398 }
399
400 Ok(Self::new_unchecked(algorithm, digits, skew, step, secret))
401 }
402
403 #[cfg(not(feature = "otpauth"))]
404 pub fn new_unchecked(
417 algorithm: Algorithm,
418 digits: usize,
419 skew: u8,
420 step: u64,
421 secret: Vec<u8>,
422 ) -> TOTP {
423 TOTP {
424 algorithm,
425 digits,
426 skew,
427 step,
428 secret,
429 }
430 }
431
432 pub fn from_rfc6238(rfc: Rfc6238) -> Result<TOTP, TotpUrlError> {
438 TOTP::try_from(rfc)
439 }
440
441 pub fn sign(&self, time: u64) -> Vec<u8> {
443 self.algorithm.sign(
444 self.secret.as_ref(),
445 (time / self.step).to_be_bytes().as_ref(),
446 )
447 }
448
449 pub fn generate(&self, time: u64) -> String {
451 let result: &[u8] = &self.sign(time);
452 let offset = (result.last().unwrap() & 15) as usize;
453 #[allow(unused_mut)]
454 let mut result =
455 u32::from_be_bytes(result[offset..offset + 4].try_into().unwrap()) & 0x7fff_ffff;
456
457 match self.algorithm {
458 Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => format!(
459 "{1:00$}",
460 self.digits,
461 result % 10_u32.pow(self.digits as u32)
462 ),
463 #[cfg(feature = "steam")]
464 Algorithm::Steam => (0..self.digits)
465 .map(|_| {
466 let c = STEAM_CHARS
467 .chars()
468 .nth(result as usize % STEAM_CHARS.len())
469 .unwrap();
470 result /= STEAM_CHARS.len() as u32;
471 c
472 })
473 .collect(),
474 }
475 }
476
477 pub fn next_step(&self, time: u64) -> u64 {
480 let step = time / self.step;
481
482 (step + 1) * self.step
483 }
484
485 pub fn next_step_current(&self) -> Result<u64, SystemTimeError> {
488 let t = system_time()?;
489 Ok(self.next_step(t))
490 }
491
492 pub fn ttl(&self) -> Result<u64, SystemTimeError> {
494 let t = system_time()?;
495 Ok(self.step - (t % self.step))
496 }
497
498 pub fn generate_current(&self) -> Result<String, SystemTimeError> {
500 let t = system_time()?;
501 Ok(self.generate(t))
502 }
503
504 pub fn check(&self, token: &str, time: u64) -> bool {
506 let basestep = time / self.step - (self.skew as u64);
507 for i in 0..(self.skew as u16) * 2 + 1 {
508 let step_time = (basestep + (i as u64)) * self.step;
509
510 if constant_time_eq(self.generate(step_time).as_bytes(), token.as_bytes()) {
511 return true;
512 }
513 }
514 false
515 }
516
517 pub fn check_current(&self, token: &str) -> Result<bool, SystemTimeError> {
519 let t = system_time()?;
520 Ok(self.check(token, t))
521 }
522
523 pub fn get_secret_base32(&self) -> String {
525 base32::encode(
526 base32::Alphabet::Rfc4648 { padding: false },
527 self.secret.as_ref(),
528 )
529 }
530
531 #[cfg(feature = "otpauth")]
533 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
534 pub fn from_url<S: AsRef<str>>(url: S) -> Result<TOTP, TotpUrlError> {
535 let (algorithm, digits, skew, step, secret, issuer, account_name) =
536 Self::parts_from_url(url)?;
537 TOTP::new(algorithm, digits, skew, step, secret, issuer, account_name)
538 }
539
540 #[cfg(feature = "otpauth")]
542 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
543 pub fn from_url_unchecked<S: AsRef<str>>(url: S) -> Result<TOTP, TotpUrlError> {
544 let (algorithm, digits, skew, step, secret, issuer, account_name) =
545 Self::parts_from_url(url)?;
546 Ok(TOTP::new_unchecked(
547 algorithm,
548 digits,
549 skew,
550 step,
551 secret,
552 issuer,
553 account_name,
554 ))
555 }
556
557 #[cfg(feature = "otpauth")]
559 fn parts_from_url<S: AsRef<str>>(
560 url: S,
561 ) -> Result<(Algorithm, usize, u8, u64, Vec<u8>, Option<String>, String), TotpUrlError> {
562 let mut algorithm = Algorithm::SHA1;
563 let mut digits = 6;
564 let mut step = 30;
565 #[cfg(feature = "zeroize")]
566 let mut secret: zeroize::Zeroizing<Vec<u8>> = zeroize::Zeroizing::new(Vec::new());
567 #[cfg(not(feature = "zeroize"))]
568 let mut secret = Vec::new();
569 let mut issuer: Option<String> = None;
570 let mut account_name: String;
571
572 let url = Url::parse(url.as_ref()).map_err(TotpUrlError::Url)?;
573 if url.scheme() != "otpauth" {
574 return Err(TotpUrlError::Scheme(url.scheme().to_string()));
575 }
576 match url.host() {
577 Some(Host::Domain("totp")) => {}
578 #[cfg(feature = "steam")]
579 Some(Host::Domain("steam")) => {
580 algorithm = Algorithm::Steam;
581 }
582 _ => {
583 return Err(TotpUrlError::Host(url.host().unwrap().to_string()));
584 }
585 }
586
587 let path = url.path().trim_start_matches('/');
588 let path = urlencoding::decode(path)
589 .map_err(|_| TotpUrlError::AccountNameDecoding(path.to_string()))?
590 .to_string();
591 if path.contains(':') {
592 let parts = path.split_once(':').unwrap();
593 issuer = Some(parts.0.to_owned());
594 account_name = parts.1.to_owned();
595 } else {
596 account_name = path;
597 }
598
599 account_name = urlencoding::decode(account_name.as_str())
600 .map_err(|_| TotpUrlError::AccountName(account_name.to_string()))?
601 .to_string();
602
603 for (key, value) in url.query_pairs() {
604 match key.as_ref() {
605 #[cfg(feature = "steam")]
606 "algorithm" if algorithm == Algorithm::Steam => {
607 }
609 "algorithm" => {
610 algorithm = match value.as_ref() {
611 "SHA1" => Algorithm::SHA1,
612 "SHA256" => Algorithm::SHA256,
613 "SHA512" => Algorithm::SHA512,
614 _ => return Err(TotpUrlError::Algorithm(value.to_string())),
615 }
616 }
617 "digits" => {
618 digits = value
619 .parse::<usize>()
620 .map_err(|_| TotpUrlError::Digits(value.to_string()))?;
621 }
622 "period" => {
623 step = value
624 .parse::<u64>()
625 .map_err(|_| TotpUrlError::Step(value.to_string()))?;
626 }
627 "secret" => {
628 #[cfg(not(feature = "zeroize"))]
629 {
630 secret = base32::decode(
631 base32::Alphabet::Rfc4648 { padding: false },
632 value.as_ref(),
633 )
634 .ok_or_else(|| TotpUrlError::Secret(value.to_string()))?;
635 }
636 #[cfg(feature = "zeroize")]
637 {
638 secret = zeroize::Zeroizing::new(
639 base32::decode(
640 base32::Alphabet::Rfc4648 { padding: false },
641 value.as_ref(),
642 )
643 .ok_or_else(|| TotpUrlError::Secret(value.to_string()))?,
644 );
645 }
646 }
647 #[cfg(feature = "steam")]
648 "issuer" if value.to_lowercase() == "steam" => {
649 algorithm = Algorithm::Steam;
650 digits = 5;
651 issuer = Some(value.into());
652 }
653 "issuer" => {
654 let param_issuer: String = value.into();
655 if issuer.is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() {
656 return Err(TotpUrlError::IssuerMistmatch(
657 issuer.as_ref().unwrap().to_string(),
658 param_issuer,
659 ));
660 }
661 issuer = Some(param_issuer);
662 #[cfg(feature = "steam")]
663 if issuer == Some("Steam".into()) {
664 algorithm = Algorithm::Steam;
665 }
666 }
667 _ => {}
668 }
669 }
670
671 #[cfg(feature = "steam")]
672 if algorithm == Algorithm::Steam {
673 digits = 5;
674 step = 30;
675 issuer = Some("Steam".into());
676 }
677
678 if secret.is_empty() {
679 return Err(TotpUrlError::Secret("".to_string()));
680 }
681
682 Ok((
683 algorithm,
684 digits,
685 1,
686 step,
687 std::mem::take(&mut secret),
688 issuer,
689 account_name,
690 ))
691 }
692
693 #[cfg(feature = "otpauth")]
698 #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
699 pub fn get_url(&self) -> String {
700 #[allow(unused_mut)]
701 let mut host = "totp";
702 #[cfg(feature = "steam")]
703 if self.algorithm == Algorithm::Steam {
704 host = "steam";
705 }
706 let account_name = urlencoding::encode(self.account_name.as_str()).to_string();
707 let mut params = vec![format!("secret={}", self.get_secret_base32())];
708 if self.digits != 6 {
709 params.push(format!("digits={}", self.digits));
710 }
711 if self.algorithm != Algorithm::SHA1 {
712 params.push(format!("algorithm={}", self.algorithm));
713 }
714 let label = if let Some(issuer) = &self.issuer {
715 let issuer = urlencoding::encode(issuer);
716 params.push(format!("issuer={}", issuer));
717 format!("{}:{}", issuer, account_name)
718 } else {
719 account_name
720 };
721 if self.step != 30 {
722 params.push(format!("period={}", self.step));
723 }
724
725 format!("otpauth://{}/{}?{}", host, label, params.join("&"))
726 }
727}
728
729#[cfg(feature = "qr")]
730#[cfg_attr(docsrs, doc(cfg(feature = "qr")))]
731impl TOTP {
732 #[deprecated(
733 since = "5.3.0",
734 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."
735 )]
736 pub fn get_qr(&self) -> Result<String, String> {
737 let url = self.get_url();
738 qrcodegen_image::draw_base64(&url)
739 }
740
741 pub fn get_qr_base64(&self) -> Result<String, String> {
754 let url = self.get_url();
755 qrcodegen_image::draw_base64(&url)
756 }
757
758 pub fn get_qr_png(&self) -> Result<Vec<u8>, String> {
770 let url = self.get_url();
771 qrcodegen_image::draw_png(&url)
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 #[cfg(feature = "gen_secret")]
781 fn default_values() {
782 let totp = TOTP::default();
783 assert_eq!(totp.algorithm, Algorithm::SHA1);
784 assert_eq!(totp.digits, 6);
785 assert_eq!(totp.skew, 1);
786 assert_eq!(totp.step, 30)
787 }
788
789 #[test]
790 #[cfg(feature = "otpauth")]
791 fn new_wrong_issuer() {
792 let totp = TOTP::new(
793 Algorithm::SHA1,
794 6,
795 1,
796 1,
797 "TestSecretSuperSecret".as_bytes().to_vec(),
798 Some("Github:".to_string()),
799 "constantoine@github.com".to_string(),
800 );
801 assert!(totp.is_err());
802 assert!(matches!(totp.unwrap_err(), TotpUrlError::Issuer(_)));
803 }
804
805 #[test]
806 #[cfg(feature = "otpauth")]
807 fn new_wrong_account_name() {
808 let totp = TOTP::new(
809 Algorithm::SHA1,
810 6,
811 1,
812 1,
813 "TestSecretSuperSecret".as_bytes().to_vec(),
814 Some("Github".to_string()),
815 "constantoine:github.com".to_string(),
816 );
817 assert!(totp.is_err());
818 assert!(matches!(totp.unwrap_err(), TotpUrlError::AccountName(_)));
819 }
820
821 #[test]
822 #[cfg(feature = "otpauth")]
823 fn new_wrong_account_name_no_issuer() {
824 let totp = TOTP::new(
825 Algorithm::SHA1,
826 6,
827 1,
828 1,
829 "TestSecretSuperSecret".as_bytes().to_vec(),
830 None,
831 "constantoine:github.com".to_string(),
832 );
833 assert!(totp.is_err());
834 assert!(matches!(totp.unwrap_err(), TotpUrlError::AccountName(_)));
835 }
836
837 #[test]
838 #[cfg(feature = "otpauth")]
839 fn comparison_ok() {
840 let reference = TOTP::new(
841 Algorithm::SHA1,
842 6,
843 1,
844 1,
845 "TestSecretSuperSecret".as_bytes().to_vec(),
846 Some("Github".to_string()),
847 "constantoine@github.com".to_string(),
848 )
849 .unwrap();
850 let test = TOTP::new(
851 Algorithm::SHA1,
852 6,
853 1,
854 1,
855 "TestSecretSuperSecret".as_bytes().to_vec(),
856 Some("Github".to_string()),
857 "constantoine@github.com".to_string(),
858 )
859 .unwrap();
860 assert_eq!(reference, test);
861 }
862
863 #[test]
864 #[cfg(not(feature = "otpauth"))]
865 fn comparison_different_algo() {
866 let reference =
867 TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
868 let test = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
869 assert_ne!(reference, test);
870 }
871
872 #[test]
873 #[cfg(not(feature = "otpauth"))]
874 fn comparison_different_digits() {
875 let reference =
876 TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
877 let test = TOTP::new(Algorithm::SHA1, 8, 1, 1, "TestSecretSuperSecret".into()).unwrap();
878 assert_ne!(reference, test);
879 }
880
881 #[test]
882 #[cfg(not(feature = "otpauth"))]
883 fn comparison_different_skew() {
884 let reference =
885 TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
886 let test = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
887 assert_ne!(reference, test);
888 }
889
890 #[test]
891 #[cfg(not(feature = "otpauth"))]
892 fn comparison_different_step() {
893 let reference =
894 TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
895 let test = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
896 assert_ne!(reference, test);
897 }
898
899 #[test]
900 #[cfg(not(feature = "otpauth"))]
901 fn comparison_different_secret() {
902 let reference =
903 TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
904 let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretDifferentSecret".into()).unwrap();
905 assert_ne!(reference, test);
906 }
907
908 #[test]
909 #[cfg(feature = "otpauth")]
910 fn url_for_secret_matches_sha1_without_issuer() {
911 let totp = TOTP::new(
912 Algorithm::SHA1,
913 6,
914 1,
915 30,
916 "TestSecretSuperSecret".as_bytes().to_vec(),
917 None,
918 "constantoine@github.com".to_string(),
919 )
920 .unwrap();
921 let url = totp.get_url();
922 assert_eq!(
923 url.as_str(),
924 "otpauth://totp/constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
925 );
926 }
927
928 #[test]
929 #[cfg(feature = "otpauth")]
930 fn url_for_secret_matches_sha1() {
931 let totp = TOTP::new(
932 Algorithm::SHA1,
933 6,
934 1,
935 30,
936 "TestSecretSuperSecret".as_bytes().to_vec(),
937 Some("Github".to_string()),
938 "constantoine@github.com".to_string(),
939 )
940 .unwrap();
941 let url = totp.get_url();
942 assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github");
943 }
944
945 #[test]
946 #[cfg(feature = "otpauth")]
947 fn url_for_secret_matches_sha256() {
948 let totp = TOTP::new(
949 Algorithm::SHA256,
950 6,
951 1,
952 30,
953 "TestSecretSuperSecret".as_bytes().to_vec(),
954 Some("Github".to_string()),
955 "constantoine@github.com".to_string(),
956 )
957 .unwrap();
958 let url = totp.get_url();
959 assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github");
960 }
961
962 #[test]
963 #[cfg(feature = "otpauth")]
964 fn url_for_secret_matches_sha512() {
965 let totp = TOTP::new(
966 Algorithm::SHA512,
967 6,
968 1,
969 30,
970 "TestSecretSuperSecret".as_bytes().to_vec(),
971 Some("Github".to_string()),
972 "constantoine@github.com".to_string(),
973 )
974 .unwrap();
975 let url = totp.get_url();
976 assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github");
977 }
978
979 #[test]
980 #[cfg(all(feature = "otpauth", feature = "gen_secret"))]
981 fn ttl() {
982 let secret = Secret::default();
983 let totp_rfc = Rfc6238::with_defaults(secret.to_bytes().unwrap()).unwrap();
984 let totp = TOTP::from_rfc6238(totp_rfc);
985 assert!(totp.is_ok());
986 }
987
988 #[test]
989 #[cfg(feature = "otpauth")]
990 fn ttl_ok() {
991 let totp = TOTP::new(
992 Algorithm::SHA512,
993 6,
994 1,
995 1,
996 "TestSecretSuperSecret".as_bytes().to_vec(),
997 Some("Github".to_string()),
998 "constantoine@github.com".to_string(),
999 )
1000 .unwrap();
1001 assert!(totp.ttl().is_ok());
1002 }
1003
1004 #[test]
1005 #[cfg(not(feature = "otpauth"))]
1006 fn returns_base32() {
1007 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1008 assert_eq!(
1009 totp.get_secret_base32().as_str(),
1010 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1011 );
1012 }
1013
1014 #[test]
1015 #[cfg(not(feature = "otpauth"))]
1016 fn generate_token() {
1017 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1018 assert_eq!(totp.generate(1000).as_str(), "659761");
1019 }
1020
1021 #[test]
1022 #[cfg(not(feature = "otpauth"))]
1023 fn generate_token_current() {
1024 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1025 let time = SystemTime::now()
1026 .duration_since(SystemTime::UNIX_EPOCH)
1027 .unwrap()
1028 .as_secs();
1029 assert_eq!(
1030 totp.generate(time).as_str(),
1031 totp.generate_current().unwrap()
1032 );
1033 }
1034
1035 #[test]
1036 #[cfg(not(feature = "otpauth"))]
1037 fn generates_token_sha256() {
1038 let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1039 assert_eq!(totp.generate(1000).as_str(), "076417");
1040 }
1041
1042 #[test]
1043 #[cfg(not(feature = "otpauth"))]
1044 fn generates_token_sha512() {
1045 let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1046 assert_eq!(totp.generate(1000).as_str(), "473536");
1047 }
1048
1049 #[test]
1050 #[cfg(not(feature = "otpauth"))]
1051 fn checks_token() {
1052 let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
1053 assert!(totp.check("659761", 1000));
1054 }
1055
1056 #[test]
1057 #[cfg(not(feature = "otpauth"))]
1058 fn checks_token_big_skew() {
1059 let totp = TOTP::new(Algorithm::SHA1, 6, 255, 1, "TestSecretSuperSecret".into()).unwrap();
1060 assert!(totp.check("659761", 1000));
1061 }
1062
1063 #[test]
1064 #[cfg(not(feature = "otpauth"))]
1065 fn checks_token_current() {
1066 let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap();
1067 assert!(totp
1068 .check_current(&totp.generate_current().unwrap())
1069 .unwrap());
1070 assert!(!totp.check_current("bogus").unwrap());
1071 }
1072
1073 #[test]
1074 #[cfg(not(feature = "otpauth"))]
1075 fn checks_token_with_skew() {
1076 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap();
1077 assert!(
1078 totp.check("174269", 1000) && totp.check("659761", 1000) && totp.check("260393", 1000)
1079 );
1080 }
1081
1082 #[test]
1083 #[cfg(not(feature = "otpauth"))]
1084 fn next_step() {
1085 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
1086 assert!(totp.next_step(0) == 30);
1087 assert!(totp.next_step(29) == 30);
1088 assert!(totp.next_step(30) == 60);
1089 }
1090
1091 #[test]
1092 #[cfg(not(feature = "otpauth"))]
1093 fn next_step_current() {
1094 let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap();
1095 let t = system_time().unwrap();
1096 assert!(totp.next_step_current().unwrap() == totp.next_step(t));
1097 }
1098
1099 #[test]
1100 #[cfg(feature = "otpauth")]
1101 fn from_url_err() {
1102 assert!(TOTP::from_url("otpauth://hotp/123").is_err());
1103 assert!(TOTP::from_url("otpauth://totp/GitHub:test").is_err());
1104 assert!(TOTP::from_url(
1105 "otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256"
1106 )
1107 .is_err());
1108 assert!(TOTP::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").is_err())
1109 }
1110
1111 #[test]
1112 #[cfg(feature = "otpauth")]
1113 fn from_url_default() {
1114 let totp =
1115 TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ")
1116 .unwrap();
1117 assert_eq!(
1118 totp.secret,
1119 base32::decode(
1120 base32::Alphabet::Rfc4648 { padding: false },
1121 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1122 )
1123 .unwrap()
1124 );
1125 assert_eq!(totp.algorithm, Algorithm::SHA1);
1126 assert_eq!(totp.digits, 6);
1127 assert_eq!(totp.skew, 1);
1128 assert_eq!(totp.step, 30);
1129 }
1130
1131 #[test]
1132 #[cfg(feature = "otpauth")]
1133 fn from_url_query() {
1134 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
1135 assert_eq!(
1136 totp.secret,
1137 base32::decode(
1138 base32::Alphabet::Rfc4648 { padding: false },
1139 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1140 )
1141 .unwrap()
1142 );
1143 assert_eq!(totp.algorithm, Algorithm::SHA256);
1144 assert_eq!(totp.digits, 8);
1145 assert_eq!(totp.skew, 1);
1146 assert_eq!(totp.step, 60);
1147 }
1148
1149 #[test]
1150 #[cfg(feature = "otpauth")]
1151 fn from_url_query_sha512() {
1152 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA512").unwrap();
1153 assert_eq!(
1154 totp.secret,
1155 base32::decode(
1156 base32::Alphabet::Rfc4648 { padding: false },
1157 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1158 )
1159 .unwrap()
1160 );
1161 assert_eq!(totp.algorithm, Algorithm::SHA512);
1162 assert_eq!(totp.digits, 8);
1163 assert_eq!(totp.skew, 1);
1164 assert_eq!(totp.step, 60);
1165 }
1166
1167 #[test]
1168 #[cfg(feature = "otpauth")]
1169 fn from_url_to_url() {
1170 let totp = TOTP::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
1171 let totp_bis = TOTP::new(
1172 Algorithm::SHA1,
1173 6,
1174 1,
1175 30,
1176 "TestSecretSuperSecret".as_bytes().to_vec(),
1177 Some("Github".to_string()),
1178 "constantoine@github.com".to_string(),
1179 )
1180 .unwrap();
1181 assert_eq!(totp.get_url(), totp_bis.get_url());
1182 }
1183
1184 #[test]
1185 #[cfg(feature = "otpauth")]
1186 fn from_url_unknown_param() {
1187 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256&foo=bar").unwrap();
1188 assert_eq!(
1189 totp.secret,
1190 base32::decode(
1191 base32::Alphabet::Rfc4648 { padding: false },
1192 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1193 )
1194 .unwrap()
1195 );
1196 assert_eq!(totp.algorithm, Algorithm::SHA256);
1197 assert_eq!(totp.digits, 8);
1198 assert_eq!(totp.skew, 1);
1199 assert_eq!(totp.step, 60);
1200 }
1201
1202 #[test]
1203 #[cfg(feature = "otpauth")]
1204 fn from_url_issuer_special() {
1205 let totp = TOTP::from_url("otpauth://totp/Github%40:constantoine%40github.com?issuer=Github%40&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
1206 let totp_bis = TOTP::new(
1207 Algorithm::SHA1,
1208 6,
1209 1,
1210 30,
1211 "TestSecretSuperSecret".as_bytes().to_vec(),
1212 Some("Github@".to_string()),
1213 "constantoine@github.com".to_string(),
1214 )
1215 .unwrap();
1216 assert_eq!(totp.get_url(), totp_bis.get_url());
1217 assert_eq!(totp.issuer.as_ref().unwrap(), "Github@");
1218 }
1219
1220 #[test]
1221 #[cfg(feature = "otpauth")]
1222 fn from_url_account_name_issuer() {
1223 let totp = TOTP::from_url("otpauth://totp/Github:constantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
1224 let totp_bis = TOTP::new(
1225 Algorithm::SHA1,
1226 6,
1227 1,
1228 30,
1229 "TestSecretSuperSecret".as_bytes().to_vec(),
1230 Some("Github".to_string()),
1231 "constantoine".to_string(),
1232 )
1233 .unwrap();
1234 assert_eq!(totp.get_url(), totp_bis.get_url());
1235 assert_eq!(totp.account_name, "constantoine");
1236 assert_eq!(totp.issuer.as_ref().unwrap(), "Github");
1237 }
1238
1239 #[test]
1240 #[cfg(feature = "otpauth")]
1241 fn from_url_account_name_issuer_encoded() {
1242 let totp = TOTP::from_url("otpauth://totp/Github%3Aconstantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
1243 let totp_bis = TOTP::new(
1244 Algorithm::SHA1,
1245 6,
1246 1,
1247 30,
1248 "TestSecretSuperSecret".as_bytes().to_vec(),
1249 Some("Github".to_string()),
1250 "constantoine".to_string(),
1251 )
1252 .unwrap();
1253 assert_eq!(totp.get_url(), totp_bis.get_url());
1254 assert_eq!(totp.account_name, "constantoine");
1255 assert_eq!(totp.issuer.as_ref().unwrap(), "Github");
1256 }
1257
1258 #[test]
1259 #[cfg(feature = "otpauth")]
1260 fn from_url_query_issuer() {
1261 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
1262 assert_eq!(
1263 totp.secret,
1264 base32::decode(
1265 base32::Alphabet::Rfc4648 { padding: false },
1266 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
1267 )
1268 .unwrap()
1269 );
1270 assert_eq!(totp.algorithm, Algorithm::SHA256);
1271 assert_eq!(totp.digits, 8);
1272 assert_eq!(totp.skew, 1);
1273 assert_eq!(totp.step, 60);
1274 assert_eq!(totp.issuer.as_ref().unwrap(), "GitHub");
1275 }
1276
1277 #[test]
1278 #[cfg(feature = "otpauth")]
1279 fn from_url_wrong_scheme() {
1280 let totp = TOTP::from_url("http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256");
1281 assert!(totp.is_err());
1282 let err = totp.unwrap_err();
1283 assert!(matches!(err, TotpUrlError::Scheme(_)));
1284 }
1285
1286 #[test]
1287 #[cfg(feature = "otpauth")]
1288 fn from_url_wrong_algo() {
1289 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5");
1290 assert!(totp.is_err());
1291 let err = totp.unwrap_err();
1292 assert!(matches!(err, TotpUrlError::Algorithm(_)));
1293 }
1294
1295 #[test]
1296 #[cfg(feature = "otpauth")]
1297 fn from_url_query_different_issuers() {
1298 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256");
1299 assert!(totp.is_err());
1300 assert!(matches!(
1301 totp.unwrap_err(),
1302 TotpUrlError::IssuerMistmatch(_, _)
1303 ));
1304 }
1305
1306 #[test]
1307 #[cfg(feature = "qr")]
1308 fn generates_qr() {
1309 use qrcodegen_image::qrcodegen;
1310 use sha2::{Digest, Sha512};
1311
1312 let totp = TOTP::new(
1313 Algorithm::SHA1,
1314 6,
1315 1,
1316 30,
1317 "TestSecretSuperSecret".as_bytes().to_vec(),
1318 Some("Github".to_string()),
1319 "constantoine@github.com".to_string(),
1320 )
1321 .unwrap();
1322 let url = totp.get_url();
1323 let qr = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium)
1324 .expect("could not generate qr");
1325 let data = qrcodegen_image::draw_canvas(qr).into_raw();
1326
1327 let hash_digest = Sha512::digest(data);
1329 assert_eq!(
1330 format!("{:x}", hash_digest).as_str(),
1331 "fbb0804f1e4f4c689d22292c52b95f0783b01b4319973c0c50dd28af23dbbbe663dce4eb05a7959086d9092341cb9f103ec5a9af4a973867944e34c063145328"
1332 );
1333 }
1334
1335 #[test]
1336 #[cfg(feature = "qr")]
1337 fn generates_qr_base64_ok() {
1338 let totp = TOTP::new(
1339 Algorithm::SHA1,
1340 6,
1341 1,
1342 1,
1343 "TestSecretSuperSecret".as_bytes().to_vec(),
1344 Some("Github".to_string()),
1345 "constantoine@github.com".to_string(),
1346 )
1347 .unwrap();
1348 let qr = totp.get_qr_base64();
1349 assert!(qr.is_ok());
1350 }
1351
1352 #[test]
1353 #[cfg(feature = "qr")]
1354 fn generates_qr_png_ok() {
1355 let totp = TOTP::new(
1356 Algorithm::SHA1,
1357 6,
1358 1,
1359 1,
1360 "TestSecretSuperSecret".as_bytes().to_vec(),
1361 Some("Github".to_string()),
1362 "constantoine@github.com".to_string(),
1363 )
1364 .unwrap();
1365 let qr = totp.get_qr_png();
1366 assert!(qr.is_ok());
1367 }
1368}