1use crate::hashing::{Cost, hash_password, hash_password_with, verify_password};
37use crate::random;
38use hmac::{Hmac, Mac};
39use rustlavel_core::{Error, Result};
40use rustlavel_http::url;
41use sha1::Sha1;
42use sha2::{Sha256, Sha512};
43use subtle::{Choice, ConstantTimeEq};
44
45pub const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
47
48pub const SECRET_BYTES: usize = 20;
56
57pub const DEFAULT_PERIOD: u64 = 30;
59
60pub const DEFAULT_DIGITS: u32 = 6;
62
63pub const DEFAULT_SKEW: u64 = 1;
70
71pub fn base32_encode(bytes: &[u8]) -> String {
77 let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
78 let mut buffer: u32 = 0;
79 let mut bits: u32 = 0;
80
81 for byte in bytes {
82 buffer = (buffer << 8) | u32::from(*byte);
83 bits += 8;
84 while bits >= 5 {
85 bits -= 5;
86 out.push(BASE32_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
87 }
88 }
89
90 if bits > 0 {
94 out.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
95 }
96
97 out
98}
99
100pub fn base32_decode(text: &str) -> Option<Vec<u8>> {
114 let mut out = Vec::with_capacity(text.len() * 5 / 8);
115 let mut buffer: u32 = 0;
116 let mut bits: u32 = 0;
117
118 for character in text.chars() {
119 if character == '=' || character.is_whitespace() {
120 continue;
121 }
122
123 let value = match character {
124 'A'..='Z' => character as u32 - 'A' as u32,
125 'a'..='z' => character as u32 - 'a' as u32,
126 '2'..='7' => character as u32 - '2' as u32 + 26,
127 _ => return None,
128 };
129
130 buffer = (buffer << 5) | value;
131 bits += 5;
132 if bits >= 8 {
133 bits -= 8;
134 out.push((buffer >> bits) as u8);
135 }
136 }
137
138 Some(out)
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum Algorithm {
154 #[default]
155 Sha1,
156 Sha256,
157 Sha512,
158}
159
160impl Algorithm {
161 pub fn name(self) -> &'static str {
163 match self {
164 Algorithm::Sha1 => "SHA1",
165 Algorithm::Sha256 => "SHA256",
166 Algorithm::Sha512 => "SHA512",
167 }
168 }
169
170 pub fn parse(name: &str) -> Option<Algorithm> {
172 match name.to_ascii_uppercase().replace('-', "").as_str() {
173 "SHA1" => Some(Algorithm::Sha1),
174 "SHA256" => Some(Algorithm::Sha256),
175 "SHA512" => Some(Algorithm::Sha512),
176 _ => None,
177 }
178 }
179}
180
181impl std::fmt::Display for Algorithm {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.write_str(self.name())
184 }
185}
186
187fn mac(algorithm: Algorithm, secret: &[u8], counter: u64) -> Vec<u8> {
195 let message = counter.to_be_bytes();
196
197 match algorithm {
198 Algorithm::Sha1 => {
199 let mut mac =
200 Hmac::<Sha1>::new_from_slice(secret).expect("HMAC accepts a key of any length");
201 mac.update(&message);
202 mac.finalize().into_bytes().to_vec()
203 }
204 Algorithm::Sha256 => {
205 let mut mac =
206 Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts a key of any length");
207 mac.update(&message);
208 mac.finalize().into_bytes().to_vec()
209 }
210 Algorithm::Sha512 => {
211 let mut mac =
212 Hmac::<Sha512>::new_from_slice(secret).expect("HMAC accepts a key of any length");
213 mac.update(&message);
214 mac.finalize().into_bytes().to_vec()
215 }
216 }
217}
218
219fn clamped_digits(digits: u32) -> u32 {
226 digits.clamp(6, 10)
227}
228
229pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
239 hotp_with(Algorithm::Sha1, secret, counter, digits)
240}
241
242pub fn hotp_with(algorithm: Algorithm, secret: &[u8], counter: u64, digits: u32) -> String {
244 let digits = clamped_digits(digits);
245 let hash = mac(algorithm, secret, counter);
246
247 let offset = (hash[hash.len() - 1] & 0x0f) as usize;
253 let binary = (u32::from(hash[offset]) & 0x7f) << 24
254 | u32::from(hash[offset + 1]) << 16
255 | u32::from(hash[offset + 2]) << 8
256 | u32::from(hash[offset + 3]);
257
258 let value = u64::from(binary) % 10u64.pow(digits);
259 format!("{value:0width$}", width = digits as usize)
260}
261
262pub fn step_of(unix_seconds: u64, period: u64) -> u64 {
288 unix_seconds / period.max(1)
289}
290
291#[derive(Clone)]
299pub struct Totp {
300 secret: Vec<u8>,
301 digits: u32,
302 period: u64,
303 algorithm: Algorithm,
304 skew: u64,
305}
306
307impl Totp {
308 pub fn generate() -> Self {
313 Totp::new(random::bytes(SECRET_BYTES))
314 }
315
316 pub fn new(secret: Vec<u8>) -> Self {
318 Totp {
319 secret,
320 digits: DEFAULT_DIGITS,
321 period: DEFAULT_PERIOD,
322 algorithm: Algorithm::default(),
323 skew: DEFAULT_SKEW,
324 }
325 }
326
327 pub fn from_base32(secret: &str) -> Result<Self> {
334 let bytes = base32_decode(secret).ok_or_else(|| {
335 Error::msg("that is not a base32 secret: it contains characters outside A-Z and 2-7")
336 })?;
337
338 if bytes.is_empty() {
339 return Err(Error::msg("a TOTP secret cannot be empty"));
340 }
341
342 Ok(Totp::new(bytes))
343 }
344
345 pub fn with_digits(mut self, digits: u32) -> Self {
347 self.digits = clamped_digits(digits);
348 self
349 }
350
351 pub fn with_period(mut self, period: u64) -> Self {
353 self.period = period.max(1);
354 self
355 }
356
357 pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
358 self.algorithm = algorithm;
359 self
360 }
361
362 pub fn with_skew(mut self, skew: u64) -> Self {
367 self.skew = skew;
368 self
369 }
370
371 pub fn secret(&self) -> &[u8] {
374 &self.secret
375 }
376
377 pub fn secret_base32(&self) -> String {
379 base32_encode(&self.secret)
380 }
381
382 pub fn digits(&self) -> u32 {
383 self.digits
384 }
385
386 pub fn period(&self) -> u64 {
387 self.period
388 }
389
390 pub fn algorithm(&self) -> Algorithm {
391 self.algorithm
392 }
393
394 pub fn skew(&self) -> u64 {
395 self.skew
396 }
397
398 pub fn at(&self, unix_seconds: u64) -> String {
400 self.at_step(step_of(unix_seconds, self.period))
401 }
402
403 pub fn at_step(&self, step: u64) -> String {
405 hotp_with(self.algorithm, &self.secret, step, self.digits)
406 }
407
408 pub fn now(&self) -> String {
410 self.at(crate::unix_now())
411 }
412
413 pub fn verify(&self, code: &str, unix_seconds: u64) -> bool {
441 if code.len() != self.digits as usize || !code.bytes().all(|byte| byte.is_ascii_digit()) {
442 return false;
443 }
444
445 let step = step_of(unix_seconds, self.period);
446 let first = step.saturating_sub(self.skew);
447 let last = step.saturating_add(self.skew);
448
449 let mut matched = Choice::from(0u8);
450 for candidate in first..=last {
451 matched |= self.at_step(candidate).as_bytes().ct_eq(code.as_bytes());
452 }
453
454 matched.into()
455 }
456
457 pub fn verify_now(&self, code: &str) -> bool {
459 self.verify(code, crate::unix_now())
460 }
461
462 pub fn provisioning_uri(&self, account: &str, issuer: &str) -> String {
476 format!(
477 "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm={}&digits={}&period={}",
478 url::encode(issuer),
479 url::encode(account),
480 self.secret_base32(),
481 url::encode(issuer),
482 self.algorithm.name(),
483 self.digits,
484 self.period,
485 )
486 }
487}
488
489impl std::fmt::Debug for Totp {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 f.debug_struct("Totp")
494 .field("secret", &"<redacted>")
495 .field("digits", &self.digits)
496 .field("period", &self.period)
497 .field("algorithm", &self.algorithm)
498 .field("skew", &self.skew)
499 .finish()
500 }
501}
502
503pub const RECOVERY_ALPHABET: &[u8; 32] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
515
516pub const RECOVERY_CODE_LENGTH: usize = 10;
521
522#[derive(Clone)]
528pub struct RecoveryCodes {
529 codes: Vec<String>,
530}
531
532impl RecoveryCodes {
533 pub fn generate(count: usize) -> RecoveryCodes {
535 let bytes = random::bytes(count * RECOVERY_CODE_LENGTH);
540
541 let codes = bytes
542 .chunks(RECOVERY_CODE_LENGTH)
543 .map(|chunk| {
544 let characters: String = chunk
545 .iter()
546 .map(|byte| RECOVERY_ALPHABET[(byte & 0x1f) as usize] as char)
547 .collect();
548 punctuate(&characters)
549 })
550 .collect();
551
552 RecoveryCodes { codes }
553 }
554
555 pub fn codes(&self) -> &[String] {
557 &self.codes
558 }
559
560 pub fn into_codes(self) -> Vec<String> {
561 self.codes
562 }
563
564 pub fn hashed(&self) -> Vec<String> {
575 self.hashed_with(Cost::DEFAULT)
576 }
577
578 pub fn hashed_with(&self, cost: Cost) -> Vec<String> {
581 self.codes
582 .iter()
583 .map(|code| {
584 hash_password_with(code, cost)
585 .expect("argon2 accepts every Cost this crate can construct")
586 })
587 .collect()
588 }
589}
590
591impl std::fmt::Debug for RecoveryCodes {
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 f.debug_struct("RecoveryCodes")
595 .field("count", &self.codes.len())
596 .field("codes", &"<redacted>")
597 .finish()
598 }
599}
600
601pub fn hash_recovery_code(code: &str) -> Result<String> {
605 hash_password(&normalise(code))
606}
607
608fn punctuate(characters: &str) -> String {
614 if characters.len() != RECOVERY_CODE_LENGTH {
615 return characters.to_string();
616 }
617 format!("{}-{}", &characters[..5], &characters[5..])
618}
619
620fn normalise(code: &str) -> String {
627 let characters: String = code
628 .chars()
629 .map(|character| character.to_ascii_uppercase())
630 .filter(|character| {
631 character.is_ascii() && RECOVERY_ALPHABET.contains(&(*character as u8))
632 })
633 .collect();
634
635 punctuate(&characters)
636}
637
638pub fn consume_recovery_code(code: &str, hashes: &mut Vec<String>) -> bool {
652 let normalised = normalise(code);
653 if normalised.is_empty() {
654 return false;
655 }
656
657 let Some(position) = hashes.iter().position(|hash| verify_password(&normalised, hash)) else {
658 return false;
659 };
660
661 hashes.remove(position);
662 true
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 const RFC_SECRET_SHA1: &[u8] = b"12345678901234567890";
671 const RFC_SECRET_SHA256: &[u8] = b"12345678901234567890123456789012";
673 const RFC_SECRET_SHA512: &[u8] =
675 b"1234567890123456789012345678901234567890123456789012345678901234";
676
677 #[test]
678 fn base32_matches_the_rfc_4648_test_vectors() {
679 let vectors = [
681 ("", ""),
682 ("f", "MY"),
683 ("fo", "MZXQ"),
684 ("foo", "MZXW6"),
685 ("foob", "MZXW6YQ"),
686 ("fooba", "MZXW6YTB"),
687 ("foobar", "MZXW6YTBOI"),
688 ];
689
690 for (plain, encoded) in vectors {
691 assert_eq!(base32_encode(plain.as_bytes()), encoded, "encoding {plain:?}");
692 assert_eq!(
693 base32_decode(encoded).as_deref(),
694 Some(plain.as_bytes()),
695 "decoding {encoded:?}"
696 );
697 }
698 }
699
700 #[test]
701 fn base32_round_trips_every_length_up_to_a_full_group() {
702 for length in 0..=32 {
703 let bytes: Vec<u8> = (0..length).map(|i| i as u8 ^ 0xa5).collect();
704 let encoded = base32_encode(&bytes);
705
706 assert!(encoded.bytes().all(|b| BASE32_ALPHABET.contains(&b)), "{encoded}");
707 assert_eq!(base32_decode(&encoded), Some(bytes), "at length {length}");
708 }
709 }
710
711 #[test]
712 fn decoding_forgives_case_spaces_and_padding() {
713 let expected = Some(b"foobar".to_vec());
714
715 assert_eq!(base32_decode("mzxw6ytboi"), expected);
716 assert_eq!(base32_decode("MZXW 6YTB OI"), expected);
717 assert_eq!(base32_decode("mzxw 6ytb OI"), expected);
718 assert_eq!(base32_decode("MZXW6YTBOI======"), expected);
719 assert_eq!(base32_decode("MZXW6YTB OI==\n"), expected);
720 }
721
722 #[test]
723 fn decoding_rejects_anything_outside_the_alphabet() {
724 for text in ["MZXW6YTB0I", "MZXW6YTB1I", "MZXW6YTB8I", "MZXW-6YTB", "MZXW6YTB!", "héllo"] {
727 assert_eq!(base32_decode(text), None, "{text:?} should not decode");
728 }
729 }
730
731 #[test]
732 fn hotp_reproduces_the_whole_rfc_4226_appendix_d_table() {
733 let expected = [
734 "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
735 "399871", "520489",
736 ];
737
738 for (counter, code) in expected.iter().enumerate() {
739 assert_eq!(
740 &hotp(RFC_SECRET_SHA1, counter as u64, 6),
741 code,
742 "RFC 4226 counter {counter}"
743 );
744 }
745 }
746
747 #[test]
748 fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha1() {
749 let vectors = [
753 (59u64, "94287082"),
754 (1_111_111_109, "07081804"),
755 (1_111_111_111, "14050471"),
756 (1_234_567_890, "89005924"),
757 (2_000_000_000, "69279037"),
758 (20_000_000_000, "65353130"),
759 ];
760
761 let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_digits(8);
762 for (at, code) in vectors {
763 assert_eq!(totp.at(at), code, "RFC 6238 SHA-1 at T={at}");
764 }
765 }
766
767 #[test]
768 fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha256() {
769 let vectors = [
770 (59u64, "46119246"),
771 (1_111_111_109, "68084774"),
772 (1_111_111_111, "67062674"),
773 (1_234_567_890, "91819424"),
774 (2_000_000_000, "90698825"),
775 (20_000_000_000, "77737706"),
776 ];
777
778 let totp = Totp::new(RFC_SECRET_SHA256.to_vec())
779 .with_digits(8)
780 .with_algorithm(Algorithm::Sha256);
781 for (at, code) in vectors {
782 assert_eq!(totp.at(at), code, "RFC 6238 SHA-256 at T={at}");
783 }
784 }
785
786 #[test]
787 fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha512() {
788 let vectors = [
789 (59u64, "90693936"),
790 (1_111_111_109, "25091201"),
791 (1_111_111_111, "99943326"),
792 (1_234_567_890, "93441116"),
793 (2_000_000_000, "38618901"),
794 (20_000_000_000, "47863826"),
795 ];
796
797 let totp = Totp::new(RFC_SECRET_SHA512.to_vec())
798 .with_digits(8)
799 .with_algorithm(Algorithm::Sha512);
800 for (at, code) in vectors {
801 assert_eq!(totp.at(at), code, "RFC 6238 SHA-512 at T={at}");
802 }
803 }
804
805 #[test]
806 fn the_rfc_6238_times_land_on_the_steps_the_rfc_prints() {
807 assert_eq!(step_of(59, 30), 0x1);
810 assert_eq!(step_of(1_111_111_109, 30), 0x23523EC);
811 assert_eq!(step_of(1_111_111_111, 30), 0x23523ED);
812 assert_eq!(step_of(1_234_567_890, 30), 0x273EF07);
813 assert_eq!(step_of(2_000_000_000, 30), 0x3F940AA);
814 assert_eq!(step_of(20_000_000_000, 30), 0x27BC86AA);
815 }
816
817 #[test]
818 fn a_step_is_the_timestamp_divided_by_the_period() {
819 assert_eq!(step_of(0, 30), 0);
820 assert_eq!(step_of(29, 30), 0);
821 assert_eq!(step_of(30, 30), 1);
822 assert_eq!(step_of(61, 60), 1);
823
824 assert_eq!(step_of(7, 0), 7);
826 }
827
828 #[test]
829 fn the_default_shape_is_six_digits_over_thirty_seconds_of_sha1() {
830 let totp = Totp::generate();
831
832 assert_eq!(totp.digits(), 6);
833 assert_eq!(totp.period(), 30);
834 assert_eq!(totp.algorithm(), Algorithm::Sha1);
835 assert_eq!(totp.skew(), 1);
836 assert_eq!(totp.secret().len(), SECRET_BYTES);
837 assert_eq!(totp.now().len(), 6);
838 assert!(totp.now().bytes().all(|b| b.is_ascii_digit()));
839 }
840
841 #[test]
842 fn two_generated_secrets_differ() {
843 assert_ne!(Totp::generate().secret(), Totp::generate().secret());
844 }
845
846 #[test]
847 fn a_secret_survives_the_base32_round_trip() {
848 let totp = Totp::generate();
849 let encoded = totp.secret_base32();
850
851 assert_eq!(encoded.len(), 32, "20 bytes is 32 base32 characters");
852 let rebuilt = Totp::from_base32(&encoded).expect("its own output should parse");
853
854 assert_eq!(rebuilt.secret(), totp.secret());
855 assert_eq!(rebuilt.at(1_234_567_890), totp.at(1_234_567_890));
856 }
857
858 #[test]
859 fn an_empty_or_malformed_secret_is_refused_rather_than_accepted_as_zero_bytes() {
860 assert!(Totp::from_base32("").is_err());
861 assert!(Totp::from_base32(" ").is_err());
862 assert!(Totp::from_base32("====").is_err());
863 assert!(Totp::from_base32("MZXW6YTB0I").is_err());
864
865 assert!(Totp::from_base32("mzxw 6ytb oi").is_ok(), "leniency still applies");
866 }
867
868 #[test]
869 fn the_current_code_verifies() {
870 let totp = Totp::generate();
871 let now = crate::unix_now();
872
873 assert!(totp.verify(&totp.at(now), now));
874 assert!(totp.verify_now(&totp.now()));
875 }
876
877 #[test]
878 fn one_step_either_side_is_accepted_and_two_are_not() {
879 let totp = Totp::new(RFC_SECRET_SHA1.to_vec());
880 let now = 1_234_567_890;
881
882 assert!(totp.verify(&totp.at(now - 30), now), "a code from the previous step");
883 assert!(totp.verify(&totp.at(now), now));
884 assert!(totp.verify(&totp.at(now + 30), now), "and one from the next");
885
886 assert!(!totp.verify(&totp.at(now - 60), now), "two steps back is outside the window");
887 assert!(!totp.verify(&totp.at(now + 60), now));
888 }
889
890 #[test]
891 fn the_window_is_configurable_in_both_directions() {
892 let secret = RFC_SECRET_SHA1.to_vec();
893 let now = 1_234_567_890;
894
895 let strict = Totp::new(secret.clone()).with_skew(0);
896 assert!(strict.verify(&strict.at(now), now));
897 assert!(!strict.verify(&strict.at(now - 30), now), "no window means no drift allowed");
898
899 let generous = Totp::new(secret).with_skew(2);
900 assert!(generous.verify(&generous.at(now - 60), now));
901 assert!(!generous.verify(&generous.at(now - 90), now));
902 }
903
904 #[test]
905 fn the_window_does_not_underflow_at_the_epoch() {
906 let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_skew(5);
909
910 assert!(totp.verify(&totp.at(0), 0));
911 assert!(!totp.verify("000000", u64::MAX));
912 }
913
914 #[test]
915 fn a_code_from_another_secret_is_rejected() {
916 let mine = Totp::generate();
917 let yours = Totp::generate();
918 let now = crate::unix_now();
919
920 assert!(!mine.verify(&yours.at(now), now));
921 assert!(!yours.verify(&mine.at(now), now));
922 }
923
924 #[test]
925 fn a_code_of_the_wrong_shape_is_rejected_without_panicking() {
926 let totp = Totp::generate();
927 let now = crate::unix_now();
928 let real = totp.at(now);
929 let too_short = real[..5].to_string();
930 let too_long = format!("{real}0");
931 let padded = format!(" {real}");
932
933 for code in [
934 "",
935 "12345",
936 "1234567",
937 "12345 ",
938 " 123456",
939 "abcdef",
940 "12345a",
941 "12-345",
942 "١٢٣٤٥٦",
944 too_short.as_str(),
945 too_long.as_str(),
946 padded.as_str(),
947 ] {
948 assert!(!totp.verify(code, now), "{code:?} should not verify");
949 }
950
951 assert!(totp.verify(&real, now));
954 }
955
956 #[test]
957 fn a_different_algorithm_produces_a_different_code_from_the_same_secret() {
958 let secret = RFC_SECRET_SHA1.to_vec();
959 let at = 1_234_567_890;
960
961 let sha1 = Totp::new(secret.clone()).with_digits(8);
962 let sha256 = Totp::new(secret.clone()).with_digits(8).with_algorithm(Algorithm::Sha256);
963 let sha512 = Totp::new(secret).with_digits(8).with_algorithm(Algorithm::Sha512);
964
965 assert_ne!(sha1.at(at), sha256.at(at));
966 assert_ne!(sha256.at(at), sha512.at(at));
967 assert!(!sha1.verify(&sha256.at(at), at), "the algorithm has to match on both ends");
968 }
969
970 #[test]
971 fn digits_are_clamped_to_the_range_rfc_4226_allows() {
972 let secret = RFC_SECRET_SHA1.to_vec();
973
974 assert_eq!(Totp::new(secret.clone()).with_digits(0).digits(), 6);
975 assert_eq!(Totp::new(secret.clone()).with_digits(4).digits(), 6);
976 assert_eq!(Totp::new(secret.clone()).with_digits(8).digits(), 8);
977 assert_eq!(Totp::new(secret.clone()).with_digits(99).digits(), 10);
978
979 assert_eq!(hotp(RFC_SECRET_SHA1, 0, 0).len(), 6);
980 assert_eq!(hotp(RFC_SECRET_SHA1, 0, 8), "84755224", "the RFC's 8-digit form of row 0");
981 assert_eq!(hotp(RFC_SECRET_SHA1, 0, 99).len(), 10);
982 }
983
984 #[test]
985 fn a_period_of_zero_is_read_as_one_second() {
986 let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_period(0);
987
988 assert_eq!(totp.period(), 1);
989 assert!(totp.verify(&totp.at(1_000), 1_000));
990 }
991
992 #[test]
993 fn the_provisioning_uri_carries_every_parameter() {
994 let totp = Totp::new(b"foobar".to_vec());
995 let uri = totp.provisioning_uri("jane@acme.test", "ACME");
996
997 assert_eq!(
998 uri,
999 "otpauth://totp/ACME:jane%40acme.test\
1000 ?secret=MZXW6YTBOI&issuer=ACME&algorithm=SHA1&digits=6&period=30"
1001 );
1002 }
1003
1004 #[test]
1005 fn a_label_with_a_space_or_a_colon_is_percent_encoded() {
1006 let totp = Totp::new(b"foobar".to_vec())
1007 .with_digits(8)
1008 .with_period(60)
1009 .with_algorithm(Algorithm::Sha512);
1010
1011 let uri = totp.provisioning_uri("jane doe:admin", "ACME Corp");
1012
1013 assert!(uri.starts_with("otpauth://totp/ACME%20Corp:jane%20doe%3Aadmin?"), "{uri}");
1016 assert_eq!(uri.matches(':').count(), 2, "one in the scheme, one separating the label");
1017
1018 assert!(uri.contains("&issuer=ACME%20Corp"));
1019 assert!(uri.contains("&algorithm=SHA512"));
1020 assert!(uri.contains("&digits=8"));
1021 assert!(uri.contains("&period=60"));
1022 }
1023
1024 #[test]
1025 fn algorithm_names_round_trip() {
1026 for algorithm in [Algorithm::Sha1, Algorithm::Sha256, Algorithm::Sha512] {
1027 assert_eq!(Algorithm::parse(algorithm.name()), Some(algorithm));
1028 assert_eq!(algorithm.to_string(), algorithm.name());
1029 }
1030
1031 assert_eq!(Algorithm::parse("sha1"), Some(Algorithm::Sha1));
1032 assert_eq!(Algorithm::parse("SHA-256"), Some(Algorithm::Sha256));
1033 assert_eq!(Algorithm::parse("md5"), None);
1034 assert_eq!(Algorithm::parse(""), None);
1035 }
1036
1037 #[test]
1038 fn a_secret_never_appears_in_debug_output() {
1039 let totp = Totp::generate();
1040 let printed = format!("{totp:?}");
1041
1042 assert!(!printed.contains(&totp.secret_base32()), "the secret escaped into {printed}");
1043 assert!(printed.contains("<redacted>"));
1044 assert!(printed.contains("digits: 6"));
1046 assert!(printed.contains("Sha1"));
1047 }
1048
1049 #[test]
1050 fn recovery_codes_are_typeable_and_unambiguous() {
1051 let codes = RecoveryCodes::generate(10);
1052
1053 assert_eq!(codes.codes().len(), 10);
1054 for code in codes.codes() {
1055 assert_eq!(code.len(), RECOVERY_CODE_LENGTH + 1, "{code} should be xxxxx-xxxxx");
1056 assert_eq!(&code[5..6], "-");
1057
1058 for character in code.chars().filter(|c| *c != '-') {
1059 assert!(RECOVERY_ALPHABET.contains(&(character as u8)), "{code} contains {character}");
1060 }
1061 for confusable in ['0', 'O', '1', 'I', 'l'] {
1062 assert!(!code.contains(confusable), "{code} contains a {confusable}");
1063 }
1064 }
1065 }
1066
1067 #[test]
1068 fn every_generated_code_is_distinct() {
1069 let codes = RecoveryCodes::generate(50);
1070 let mut seen = codes.codes().to_vec();
1071 seen.sort();
1072 seen.dedup();
1073
1074 assert_eq!(seen.len(), 50, "50 bits of entropy should not collide");
1075 assert_ne!(RecoveryCodes::generate(1).codes(), RecoveryCodes::generate(1).codes());
1076 }
1077
1078 #[test]
1079 fn a_recovery_code_works_exactly_once() {
1080 let codes = RecoveryCodes::generate(3);
1081 let mut hashes = codes.hashed_with(Cost::FAST);
1082 let spent = codes.codes()[1].clone();
1083
1084 assert!(consume_recovery_code(&spent, &mut hashes), "the first use should be accepted");
1085 assert_eq!(hashes.len(), 2, "the hash it matched must be gone");
1086
1087 assert!(!consume_recovery_code(&spent, &mut hashes), "the second use must not be");
1088 assert_eq!(hashes.len(), 2);
1089
1090 assert!(consume_recovery_code(&codes.codes()[0], &mut hashes));
1092 assert!(consume_recovery_code(&codes.codes()[2], &mut hashes));
1093 assert!(hashes.is_empty());
1094 }
1095
1096 #[test]
1097 fn an_unknown_code_is_refused_and_removes_nothing() {
1098 let codes = RecoveryCodes::generate(3);
1099 let mut hashes = codes.hashed_with(Cost::FAST);
1100
1101 for guess in ["", "-", "22222-22222", "not a code", &codes.codes()[0].replace('-', "X")] {
1102 assert!(!consume_recovery_code(guess, &mut hashes), "{guess:?} should not be accepted");
1103 }
1104 assert_eq!(hashes.len(), 3);
1105
1106 let others = RecoveryCodes::generate(1);
1108 assert!(!consume_recovery_code(&others.codes()[0], &mut hashes));
1109 assert_eq!(hashes.len(), 3);
1110 }
1111
1112 #[test]
1113 fn a_code_is_accepted_however_it_was_typed() {
1114 let codes = RecoveryCodes::generate(1);
1115 let code = codes.codes()[0].clone();
1116
1117 for typed in [
1118 code.clone(),
1119 code.to_lowercase(),
1120 code.replace('-', ""),
1121 code.replace('-', " "),
1122 format!(" {code} "),
1123 ] {
1124 let mut hashes = codes.hashed_with(Cost::FAST);
1125 assert!(consume_recovery_code(&typed, &mut hashes), "{typed:?} should be accepted");
1126 }
1127 }
1128
1129 #[test]
1130 fn the_stored_hashes_reveal_nothing_about_the_codes() {
1131 let codes = RecoveryCodes::generate(5);
1132 let hashes = codes.hashed_with(Cost::FAST);
1133 let stored = hashes.join(" ");
1134
1135 for code in codes.codes() {
1136 assert!(!stored.contains(code.as_str()), "{code} was stored in the clear");
1137 assert!(!stored.contains(&code.replace('-', "")));
1138 }
1139
1140 for hash in &hashes {
1141 assert!(hash.starts_with("$argon2id$"), "unexpected hash: {hash}");
1142 }
1143
1144 let repeated = RecoveryCodes { codes: vec!["ABCDE-FGHJK".into(), "ABCDE-FGHJK".into()] };
1147 let pair = repeated.hashed_with(Cost::FAST);
1148 assert_ne!(pair[0], pair[1]);
1149 }
1150
1151 #[test]
1152 fn a_set_of_codes_never_prints_itself() {
1153 let codes = RecoveryCodes::generate(2);
1154 let printed = format!("{codes:?}");
1155
1156 for code in codes.codes() {
1157 assert!(!printed.contains(code.as_str()), "a code escaped into {printed}");
1158 }
1159 assert!(printed.contains("<redacted>"));
1160 assert!(printed.contains("count: 2"));
1161 }
1162
1163 #[test]
1164 fn a_single_code_can_be_hashed_and_spent_on_its_own() {
1165 let codes = RecoveryCodes::generate(1);
1166 let code = codes.codes()[0].clone();
1167
1168 let mut hashes = vec![hash_recovery_code(&code).expect("the default cost is valid")];
1169
1170 assert!(consume_recovery_code(&code.to_lowercase(), &mut hashes));
1171 assert!(hashes.is_empty());
1172 }
1173}