1#![deny(missing_docs)]
2#![forbid(unsafe_code)]
3
4mod error;
8
9#[cfg(feature = "qr")]
10pub mod qr;
11
12pub use error::Error;
13
14pub type Result<T> = std::result::Result<T, Error>;
16
17use constant_time_eq::constant_time_eq;
18use hmac::Mac;
19use std::{
20 fmt,
21 time::{SystemTime, UNIX_EPOCH},
22};
23use url::{Host, Url};
24
25#[cfg(feature = "serde")]
26use serde::{Deserialize, Serialize};
27
28type HmacSha1 = hmac::Hmac<sha1::Sha1>;
29type HmacSha256 = hmac::Hmac<sha2::Sha256>;
30type HmacSha512 = hmac::Hmac<sha2::Sha512>;
31
32#[derive(Debug, Copy, Clone, Eq, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
35pub enum Algorithm {
36 SHA1,
38 SHA256,
40 SHA512,
42}
43
44impl std::default::Default for Algorithm {
45 fn default() -> Self {
46 Algorithm::SHA1
47 }
48}
49
50impl fmt::Display for Algorithm {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Algorithm::SHA1 => f.write_str("SHA1"),
54 Algorithm::SHA256 => f.write_str("SHA256"),
55 Algorithm::SHA512 => f.write_str("SHA512"),
56 }
57 }
58}
59
60impl Algorithm {
61 fn hash<D>(mut digest: D, data: &[u8]) -> Vec<u8>
62 where
63 D: Mac,
64 {
65 digest.update(data);
66 digest.finalize().into_bytes().to_vec()
67 }
68
69 fn sign(&self, key: &[u8], data: &[u8]) -> Vec<u8> {
70 match self {
71 Algorithm::SHA1 => {
72 Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data)
73 }
74 Algorithm::SHA256 => Algorithm::hash(
75 HmacSha256::new_from_slice(key).unwrap(),
76 data,
77 ),
78 Algorithm::SHA512 => Algorithm::hash(
79 HmacSha512::new_from_slice(key).unwrap(),
80 data,
81 ),
82 }
83 }
84}
85
86fn system_time() -> Result<u64> {
87 let t = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
88 Ok(t)
89}
90
91#[derive(Debug, Clone)]
93#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
94#[cfg_attr(
95 feature = "zeroize",
96 derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop)
97)]
98pub struct TOTP {
99 #[cfg_attr(feature = "zeroize", zeroize(skip))]
109 pub algorithm: Algorithm,
110
111 pub digits: usize,
116
117 pub skew: u8,
124
125 pub step: u64,
129
130 pub secret: Vec<u8>,
135
136 pub account_name: String,
142
143 pub issuer: Option<String>,
149}
150
151impl PartialEq for TOTP {
152 fn eq(&self, other: &Self) -> bool {
153 constant_time_eq(self.secret.as_ref(), other.secret.as_ref())
154 }
155}
156
157impl TOTP {
158 pub fn new(
168 algorithm: Algorithm,
169 digits: usize,
170 skew: u8,
171 step: u64,
172 secret: Vec<u8>,
173 account_name: String,
174 issuer: Option<String>,
175 ) -> Result<TOTP> {
176 if !(6..=8).contains(&digits) {
177 return Err(Error::InvalidDigits(digits));
178 }
179
180 if secret.len() < 16 {
181 return Err(Error::SecretTooSmall(secret.len() * 8));
182 }
183
184 if account_name.contains(':') {
185 return Err(Error::AccountName(account_name));
186 }
187
188 if let Some(issuer) = &issuer {
189 if issuer.contains(':') {
190 return Err(Error::Issuer(issuer.to_string()));
191 }
192 }
193
194 Ok(TOTP {
195 algorithm,
196 digits,
197 skew,
198 step,
199 secret,
200 account_name,
201 issuer,
202 })
203 }
204
205 pub fn sign(&self, time: u64) -> Vec<u8> {
207 self.algorithm.sign(
208 self.secret.as_ref(),
209 (time / self.step).to_be_bytes().as_ref(),
210 )
211 }
212
213 pub fn generate(&self, time: u64) -> String {
215 let result: &[u8] = &self.sign(time);
216 let offset = (result.last().unwrap() & 15) as usize;
217 let result = u32::from_be_bytes(
218 result[offset..offset + 4].try_into().unwrap(),
219 ) & 0x7fff_ffff;
220 format!(
221 "{1:00$}",
222 self.digits,
223 result % 10_u32.pow(self.digits as u32)
224 )
225 }
226
227 pub fn next_step(&self, time: u64) -> u64 {
230 let step = time / self.step;
231 (step + 1) * self.step
232 }
233
234 pub fn next_step_current(&self) -> Result<u64> {
237 let t = system_time()?;
238 Ok(self.next_step(t))
239 }
240
241 pub fn ttl(&self) -> Result<u64> {
243 let t = system_time()?;
244 Ok(self.step - (t % self.step))
245 }
246
247 pub fn generate_current(&self) -> Result<String> {
249 let t = system_time()?;
250 Ok(self.generate(t))
251 }
252
253 pub fn check(&self, token: &str, time: u64) -> bool {
256 let basestep = time / self.step - (self.skew as u64);
257 for i in 0..self.skew * 2 + 1 {
258 let step_time = (basestep + (i as u64)) * (self.step as u64);
259
260 if constant_time_eq(
261 self.generate(step_time).as_bytes(),
262 token.as_bytes(),
263 ) {
264 return true;
265 }
266 }
267 false
268 }
269
270 pub fn check_current(&self, token: &str) -> Result<bool> {
273 let t = system_time()?;
274 Ok(self.check(token, t))
275 }
276
277 pub fn to_secret_base32(&self) -> String {
281 base32::encode(
282 base32::Alphabet::RFC4648 { padding: false },
283 self.secret.as_ref(),
284 )
285 }
286
287 pub fn from_secret_base32<S: AsRef<str>>(secret: S) -> Result<TOTP> {
292 let buffer = base32::decode(
293 base32::Alphabet::RFC4648 { padding: false },
294 secret.as_ref(),
295 )
296 .ok_or(Error::Secret(secret.as_ref().to_string()))?;
297
298 TOTP::new(Algorithm::SHA1, 6, 1, 30, buffer, String::new(), None)
299 }
300
301 pub fn from_url<S: AsRef<str>>(url: S) -> Result<TOTP> {
303 let url = Url::parse(url.as_ref())?;
304
305 if url.scheme() != "otpauth" {
306 return Err(Error::Scheme(url.scheme().to_string()));
307 }
308 if url.host() != Some(Host::Domain("totp")) {
309 return Err(Error::Host(url.host().unwrap().to_string()));
310 }
311
312 let mut algorithm = Algorithm::SHA1;
313 let mut digits = 6;
314 let mut step = 30;
315 let mut secret = Vec::new();
316 let mut account_name: String;
317 let mut issuer: Option<String> = None;
318
319 let path = url.path().trim_start_matches('/');
320 if path.contains(':') {
321 let parts = path.split_once(':').unwrap();
322 issuer = Some(
323 urlencoding::decode(parts.0.to_owned().as_str())
324 .map_err(|_| Error::IssuerDecoding(parts.0.to_owned()))?
325 .to_string(),
326 );
327 account_name = parts.1.trim_start_matches(':').to_owned();
328 } else {
329 account_name = path.to_owned();
330 }
331
332 account_name = urlencoding::decode(account_name.as_str())
333 .map_err(|_| Error::AccountName(account_name.to_string()))?
334 .to_string();
335
336 for (key, value) in url.query_pairs() {
337 match key.as_ref() {
338 "algorithm" => {
339 algorithm = match value.as_ref() {
340 "SHA1" => Algorithm::SHA1,
341 "SHA256" => Algorithm::SHA256,
342 "SHA512" => Algorithm::SHA512,
343 _ => return Err(Error::Algorithm(value.to_string())),
344 }
345 }
346 "digits" => {
347 digits = value
348 .parse::<usize>()
349 .map_err(|_| Error::Digits(value.to_string()))?;
350 }
351 "period" => {
352 step = value
353 .parse::<u64>()
354 .map_err(|_| Error::Step(value.to_string()))?;
355 }
356 "secret" => {
357 secret = base32::decode(
358 base32::Alphabet::RFC4648 { padding: false },
359 value.as_ref(),
360 )
361 .ok_or_else(|| Error::Secret(value.to_string()))?;
362 }
363 "issuer" => {
364 let param_issuer = value
365 .parse::<String>()
366 .map_err(|_| Error::Issuer(value.to_string()))?;
367 if issuer.is_some()
368 && param_issuer.as_str() != issuer.as_ref().unwrap()
369 {
370 return Err(Error::IssuerMismatch(
371 issuer.as_ref().unwrap().to_string(),
372 param_issuer,
373 ));
374 }
375 issuer = Some(param_issuer);
376 }
377 _ => {}
378 }
379 }
380
381 if secret.is_empty() {
382 return Err(Error::Secret("".to_string()));
383 }
384
385 TOTP::new(algorithm, digits, 1, step, secret, account_name, issuer)
386 }
387
388 pub fn get_url(&self) -> String {
395 let account_name: String =
396 urlencoding::encode(self.account_name.as_str()).to_string();
397 let mut label: String = format!("{}?", account_name);
398 if self.issuer.is_some() {
399 let issuer: String =
400 urlencoding::encode(self.issuer.as_ref().unwrap().as_str())
401 .to_string();
402 label = format!("{0}:{1}?issuer={0}&", issuer, account_name);
403 }
404
405 format!(
406 "otpauth://totp/{}secret={}&digits={}&algorithm={}",
407 label,
408 self.to_secret_base32(),
409 self.digits,
410 self.algorithm,
411 )
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn new_wrong_issuer() {
421 let totp = TOTP::new(
422 Algorithm::SHA1,
423 6,
424 1,
425 1,
426 "TestSecretSuperSecret".as_bytes().to_vec(),
427 "mock@example.com".to_string(),
428 Some("Github:".to_string()),
429 );
430 assert!(totp.is_err());
431 assert!(matches!(totp.unwrap_err(), Error::Issuer(_)));
432 }
433
434 #[test]
435 fn new_wrong_account_name() {
436 let totp = TOTP::new(
437 Algorithm::SHA1,
438 6,
439 1,
440 1,
441 "TestSecretSuperSecret".as_bytes().to_vec(),
442 "mock:example.com".to_string(),
443 Some("Github".to_string()),
444 );
445 assert!(totp.is_err());
446 assert!(matches!(totp.unwrap_err(), Error::AccountName(_)));
447 }
448
449 #[test]
450 fn new_wrong_account_name_no_issuer() {
451 let totp = TOTP::new(
452 Algorithm::SHA1,
453 6,
454 1,
455 1,
456 "TestSecretSuperSecret".as_bytes().to_vec(),
457 "mock:example.com".to_string(),
458 None,
459 );
460 assert!(totp.is_err());
461 assert!(matches!(totp.unwrap_err(), Error::AccountName(_)));
462 }
463
464 #[test]
465 fn comparison_ok() {
466 let reference = TOTP::new(
467 Algorithm::SHA1,
468 6,
469 1,
470 1,
471 "TestSecretSuperSecret".as_bytes().to_vec(),
472 "mock@example.com".to_string(),
473 Some("Github".to_string()),
474 )
475 .unwrap();
476 let test = TOTP::new(
477 Algorithm::SHA1,
478 6,
479 1,
480 1,
481 "TestSecretSuperSecret".as_bytes().to_vec(),
482 "mock@example.com".to_string(),
483 Some("Github".to_string()),
484 )
485 .unwrap();
486 assert_eq!(reference, test);
487 }
488
489 #[test]
490 fn url_for_secret_matches_sha1_without_issuer() {
491 let totp = TOTP::new(
492 Algorithm::SHA1,
493 6,
494 1,
495 1,
496 "TestSecretSuperSecret".as_bytes().to_vec(),
497 "mock@example.com".to_string(),
498 None,
499 )
500 .unwrap();
501 let url = totp.get_url();
502 assert_eq!(url.as_str(), "otpauth://totp/mock%40example.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1");
503 }
504
505 #[test]
506 fn url_for_secret_matches_sha1() {
507 let totp = TOTP::new(
508 Algorithm::SHA1,
509 6,
510 1,
511 1,
512 "TestSecretSuperSecret".as_bytes().to_vec(),
513 "mock@example.com".to_string(),
514 Some("Github".to_string()),
515 )
516 .unwrap();
517 let url = totp.get_url();
518 assert_eq!(url.as_str(), "otpauth://totp/Github:mock%40example.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1");
519 }
520
521 #[test]
522 fn url_for_secret_matches_sha256() {
523 let totp = TOTP::new(
524 Algorithm::SHA256,
525 6,
526 1,
527 1,
528 "TestSecretSuperSecret".as_bytes().to_vec(),
529 "mock@example.com".to_string(),
530 Some("Github".to_string()),
531 )
532 .unwrap();
533 let url = totp.get_url();
534 assert_eq!(url.as_str(), "otpauth://totp/Github:mock%40example.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA256");
535 }
536
537 #[test]
538 fn url_for_secret_matches_sha512() {
539 let totp = TOTP::new(
540 Algorithm::SHA512,
541 6,
542 1,
543 1,
544 "TestSecretSuperSecret".as_bytes().to_vec(),
545 "mock@example.com".to_string(),
546 Some("Github".to_string()),
547 )
548 .unwrap();
549 let url = totp.get_url();
550 assert_eq!(url.as_str(), "otpauth://totp/Github:mock%40example.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA512");
551 }
552
553 #[test]
554 fn ttl_ok() {
555 let totp = TOTP::new(
556 Algorithm::SHA512,
557 6,
558 1,
559 1,
560 "TestSecretSuperSecret".as_bytes().to_vec(),
561 "mock@example.com".to_string(),
562 Some("Github".to_string()),
563 )
564 .unwrap();
565 assert!(totp.ttl().is_ok());
566 }
567
568 #[test]
569 fn returns_base32() {
570 let totp = TOTP::new(
571 Algorithm::SHA1,
572 6,
573 1,
574 1,
575 "TestSecretSuperSecret".as_bytes().to_vec(),
576 "mock@example.com".to_string(),
577 None,
578 )
579 .unwrap();
580 assert_eq!(
581 totp.to_secret_base32().as_str(),
582 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
583 );
584 }
585
586 #[test]
587 fn generate_token() {
588 let totp = TOTP::new(
589 Algorithm::SHA1,
590 6,
591 1,
592 1,
593 "TestSecretSuperSecret".as_bytes().to_vec(),
594 "mock@example.com".to_string(),
595 None,
596 )
597 .unwrap();
598 assert_eq!(totp.generate(1000).as_str(), "659761");
599 }
600
601 #[test]
602 fn generate_token_current() {
603 let totp = TOTP::new(
604 Algorithm::SHA1,
605 6,
606 1,
607 1,
608 "TestSecretSuperSecret".as_bytes().to_vec(),
609 "mock@example.com".to_string(),
610 None,
611 )
612 .unwrap();
613 let time = SystemTime::now()
614 .duration_since(SystemTime::UNIX_EPOCH)
615 .unwrap()
616 .as_secs();
617 assert_eq!(
618 totp.generate(time).as_str(),
619 totp.generate_current().unwrap()
620 );
621 }
622
623 #[test]
624 fn generates_token_sha256() {
625 let totp = TOTP::new(
626 Algorithm::SHA256,
627 6,
628 1,
629 1,
630 "TestSecretSuperSecret".as_bytes().to_vec(),
631 "mock@example.com".to_string(),
632 None,
633 )
634 .unwrap();
635 assert_eq!(totp.generate(1000).as_str(), "076417");
636 }
637
638 #[test]
639 fn generates_token_sha512() {
640 let totp = TOTP::new(
641 Algorithm::SHA512,
642 6,
643 1,
644 1,
645 "TestSecretSuperSecret".as_bytes().to_vec(),
646 "mock@example.com".to_string(),
647 None,
648 )
649 .unwrap();
650 assert_eq!(totp.generate(1000).as_str(), "473536");
651 }
652
653 #[test]
654 fn checks_token() {
655 let totp = TOTP::new(
656 Algorithm::SHA1,
657 6,
658 0,
659 1,
660 "TestSecretSuperSecret".as_bytes().to_vec(),
661 "mock@example.com".to_string(),
662 None,
663 )
664 .unwrap();
665 assert!(totp.check("659761", 1000));
666 }
667
668 #[test]
669 fn checks_token_current() {
670 let totp = TOTP::new(
671 Algorithm::SHA1,
672 6,
673 0,
674 1,
675 "TestSecretSuperSecret".as_bytes().to_vec(),
676 "mock@example.com".to_string(),
677 None,
678 )
679 .unwrap();
680 assert!(totp
681 .check_current(&totp.generate_current().unwrap())
682 .unwrap());
683 assert!(!totp.check_current("bogus").unwrap());
684 }
685
686 #[test]
687 fn checks_token_with_skew() {
688 let totp = TOTP::new(
689 Algorithm::SHA1,
690 6,
691 1,
692 1,
693 "TestSecretSuperSecret".as_bytes().to_vec(),
694 "mock@example.com".to_string(),
695 None,
696 )
697 .unwrap();
698 assert!(
699 totp.check("174269", 1000)
700 && totp.check("659761", 1000)
701 && totp.check("260393", 1000)
702 );
703 }
704
705 #[test]
706 fn next_step() {
707 let totp = TOTP::new(
708 Algorithm::SHA1,
709 6,
710 1,
711 30,
712 "TestSecretSuperSecret".as_bytes().to_vec(),
713 "mock@example.com".to_string(),
714 Some("Mock Service".to_string()),
715 )
716 .unwrap();
717 assert!(totp.next_step(0) == 30);
718 assert!(totp.next_step(29) == 30);
719 assert!(totp.next_step(30) == 60);
720 }
721
722 #[test]
723 fn from_url_err() {
724 assert!(TOTP::from_url("otpauth://hotp/123").is_err());
725 assert!(TOTP::from_url("otpauth://totp/GitHub:test").is_err());
726 assert!(TOTP::from_url(
727 "otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256"
728 )
729 .is_err());
730 assert!(TOTP::from_url("otpauth://totp/Github:mock%40example.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").is_err())
731 }
732
733 #[test]
734 fn from_url_default() {
735 let totp = TOTP::from_url(
736 "otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ",
737 )
738 .unwrap();
739 assert_eq!(
740 totp.secret,
741 base32::decode(
742 base32::Alphabet::RFC4648 { padding: false },
743 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
744 )
745 .unwrap()
746 );
747 assert_eq!(totp.algorithm, Algorithm::SHA1);
748 assert_eq!(totp.digits, 6);
749 assert_eq!(totp.skew, 1);
750 assert_eq!(totp.step, 30);
751 }
752
753 #[test]
754 fn from_url_query() {
755 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
756 assert_eq!(
757 totp.secret,
758 base32::decode(
759 base32::Alphabet::RFC4648 { padding: false },
760 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
761 )
762 .unwrap()
763 );
764 assert_eq!(totp.algorithm, Algorithm::SHA256);
765 assert_eq!(totp.digits, 8);
766 assert_eq!(totp.skew, 1);
767 assert_eq!(totp.step, 60);
768 }
769
770 #[test]
771 fn from_url_query_sha512() {
772 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA512").unwrap();
773 assert_eq!(
774 totp.secret,
775 base32::decode(
776 base32::Alphabet::RFC4648 { padding: false },
777 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
778 )
779 .unwrap()
780 );
781 assert_eq!(totp.algorithm, Algorithm::SHA512);
782 assert_eq!(totp.digits, 8);
783 assert_eq!(totp.skew, 1);
784 assert_eq!(totp.step, 60);
785 }
786
787 #[test]
788 fn from_url_to_url() {
789 let totp = TOTP::from_url("otpauth://totp/Github:mock%40example.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
790 let totp_bis = TOTP::new(
791 Algorithm::SHA1,
792 6,
793 1,
794 1,
795 "TestSecretSuperSecret".as_bytes().to_vec(),
796 "mock@example.com".to_string(),
797 Some("Github".to_string()),
798 )
799 .unwrap();
800 assert_eq!(totp.get_url(), totp_bis.get_url());
801 }
802
803 #[test]
804 fn from_url_unknown_param() {
805 let totp = TOTP::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256&foo=bar").unwrap();
806 assert_eq!(
807 totp.secret,
808 base32::decode(
809 base32::Alphabet::RFC4648 { padding: false },
810 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
811 )
812 .unwrap()
813 );
814 assert_eq!(totp.algorithm, Algorithm::SHA256);
815 assert_eq!(totp.digits, 8);
816 assert_eq!(totp.skew, 1);
817 assert_eq!(totp.step, 60);
818 }
819
820 #[test]
821 fn from_url_issuer_special() {
822 let totp = TOTP::from_url("otpauth://totp/Github%40:mock%40example.com?issuer=Github%40&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
823 let totp_bis = TOTP::new(
824 Algorithm::SHA1,
825 6,
826 1,
827 1,
828 "TestSecretSuperSecret".as_bytes().to_vec(),
829 "mock@example.com".to_string(),
830 Some("Github@".to_string()),
831 )
832 .unwrap();
833 assert_eq!(totp.get_url(), totp_bis.get_url());
834 assert_eq!(totp.issuer.as_ref().unwrap(), "Github@");
835 }
836
837 #[test]
838 fn from_url_query_issuer() {
839 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
840 assert_eq!(
841 totp.secret,
842 base32::decode(
843 base32::Alphabet::RFC4648 { padding: false },
844 "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
845 )
846 .unwrap()
847 );
848 assert_eq!(totp.algorithm, Algorithm::SHA256);
849 assert_eq!(totp.digits, 8);
850 assert_eq!(totp.skew, 1);
851 assert_eq!(totp.step, 60);
852 assert_eq!(totp.issuer.as_ref().unwrap(), "GitHub");
853 }
854
855 #[test]
856 fn from_url_wrong_scheme() {
857 let totp = TOTP::from_url("http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256");
858 assert!(totp.is_err());
859 let err = totp.unwrap_err();
860 assert!(matches!(err, Error::Scheme(_)));
861 }
862
863 #[test]
864 fn from_url_wrong_algo() {
865 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5");
866 assert!(totp.is_err());
867 let err = totp.unwrap_err();
868 assert!(matches!(err, Error::Algorithm(_)));
869 }
870
871 #[test]
872 fn from_url_query_different_issuers() {
873 let totp = TOTP::from_url("otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256");
874 assert!(totp.is_err());
875 assert!(matches!(totp.unwrap_err(), Error::IssuerMismatch(_, _)));
876 }
877}