Skip to main content

totp_rs/
token.rs

1use crate::Algorithm;
2use core::fmt;
3
4/// Represents a token generated by [`Totp`](crate::Totp).
5/// This can be thought of as a string with a few notable difference:
6///
7/// * It is _only_ stack allocated
8/// * Comparison is done in constant time regardless of the number of digits in the token
9/// * Formatting is lazily evaluated
10///
11/// Since it implements [`Display`](core::fmt::Display), it can be directly
12/// used in formatting just like a string:
13///
14/// ```
15/// # use totp_rs::Token;
16/// # extern crate alloc;
17/// # use alloc::{format, string::String};
18/// # #[cfg(feature = "alloc")]
19/// # fn _foo(token: Token) {
20/// let text: String = format!("{}", token);
21/// # }
22/// ```
23///
24/// While [`Token`] implements [`Eq`], it's strongly recommended that you rely on
25/// [`check`](crate::Totp::check) instead, as that will take skew into account.
26///
27/// Note that while [`Token`] is stack-allocated and only contains plain data, it
28/// does _not_ implement [`Copy`].
29/// This is to preserve compatibility with the `zeroize` feature which will add
30/// a non-trivial [`Drop`] implementation.
31#[derive(Clone, Eq)]
32#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
33pub struct Token {
34    #[cfg_attr(feature = "zeroize", zeroize(skip))]
35    algorithm: Algorithm,
36    #[cfg_attr(feature = "zeroize", zeroize(skip))]
37    digits: u8,
38    /// # Invariants
39    ///
40    /// * `self.value == self.value & 0x7FFF_FFFF;`
41    /// * `self.value == self.value % Self::modulo(self.algorithm, self.digits);`
42    value: u32,
43}
44
45impl Token {
46    pub(crate) const fn new(algorithm: Algorithm, digits: u8, value: u32) -> Self {
47        Self {
48            algorithm,
49            digits,
50            value: (value & 0x7FFF_FFFF) % Self::modulo(algorithm, digits),
51        }
52    }
53
54    pub(crate) const fn from_signature(algorithm: Algorithm, digits: u8, signature: &[u8]) -> Self {
55        let last = *signature.last().unwrap();
56        let offset = (last & 0xF) as usize;
57        let value = u32::from_be_bytes([
58            signature[offset],
59            signature[offset + 1],
60            signature[offset + 2],
61            signature[offset + 3],
62        ]);
63
64        Self::new(algorithm, digits, value)
65    }
66
67    pub(crate) const fn try_from_formatted_string(
68        algorithm: Algorithm,
69        digits: u8,
70        string: &str,
71    ) -> Option<Self> {
72        if string.len() != digits as usize {
73            return None;
74        }
75
76        let value = match algorithm {
77            Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => {
78                // `from_str_radix` accepts a leading `+`, which is not a valid
79                // token character. Reject anything but ASCII digits first, then
80                // rely on the parse only to read the value and catch overflow.
81                let bytes = string.as_bytes();
82                let mut i = 0;
83                while i < bytes.len() {
84                    if !bytes[i].is_ascii_digit() {
85                        return None;
86                    }
87                    i += 1;
88                }
89
90                match u32::from_str_radix(string, 10) {
91                    Ok(value) => value,
92                    Err(_) => return None,
93                }
94            }
95            #[cfg(feature = "steam")]
96            Algorithm::Steam => {
97                let radix = STEAM_CHARS.len();
98                let mut value = 0;
99                let mut place = 1;
100                let mut bytes = string.as_bytes();
101
102                while let [byte, rest @ ..] = bytes {
103                    let mut i = 0;
104                    let mut digits = STEAM_CHARS;
105                    let index = loop {
106                        match digits {
107                            [x, _rest @ ..] if *x == *byte => break i,
108                            [_, rest @ ..] => {
109                                i += 1;
110                                digits = rest;
111                            }
112                            [] => return None,
113                        }
114                    };
115
116                    value += index * place;
117                    bytes = rest;
118                    place *= radix;
119                }
120
121                value as u32
122            }
123        };
124
125        Some(Self::new(algorithm, digits, value))
126    }
127
128    const fn modulo(algorithm: Algorithm, digits: u8) -> u32 {
129        match algorithm {
130            Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => 10_u32.checked_pow(digits as u32)
131                .expect("a `digits` value over 9 is a guaranteed corruption as 10^10 is 10_000_000_000, which does not fit in an u32."),
132            #[cfg(feature = "steam")]
133            Algorithm::Steam => (STEAM_CHARS.len() as u32).checked_pow(digits as u32)
134                .expect("a `digits` value over 6 is a guaranteed corruption as 26^7 is 8_031_810_176, which does not fit in an u32."),
135        }
136    }
137}
138
139impl PartialEq for Token {
140    // [`algorithm`](Token::algorithm) and [`digits`](Token::digits) are not considered
141    // secret, so their comparison need not be constant time.
142    fn eq(&self, other: &Self) -> bool {
143        constant_time_eq::constant_time_eq_n(&self.value.to_ne_bytes(), &other.value.to_ne_bytes())
144            && self.algorithm == other.algorithm
145            && self.digits == other.digits
146    }
147}
148
149impl fmt::Debug for Token {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        <Self as fmt::Display>::fmt(self, f)
152    }
153}
154
155impl fmt::Display for Token {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self.algorithm {
158            Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => write!(
159                f,
160                "{1:00$}",
161                self.digits.into(),
162                self.value % 10_u32.checked_pow(self.digits.into())
163                    .expect("a `digits` value over 9 is a guaranteed corruption as 10^10 is 10_000_000_000, which does not fit in an u32."),
164            ),
165            #[cfg(feature = "steam")]
166            Algorithm::Steam => {
167                use core::fmt::Write as _;
168
169                if self.digits >= 7 {
170                    panic!("a `digits` value over 6 is a guaranteed corruption as 26^7 is 8_031_810_176, which does not fit in an u32.")
171                }
172
173                let chars = (0..self.digits).scan(self.value, |value, _| {
174                    let digit = *value as usize % STEAM_CHARS.len();
175                    *value /= STEAM_CHARS.len() as u32;
176                    Some(char::from(STEAM_CHARS[digit]))
177                });
178
179                for c in chars {
180                    f.write_char(c)?;
181                }
182
183                Ok(())
184            }
185        }
186    }
187}
188
189/// Alphabet for Steam tokens.
190#[cfg(feature = "steam")]
191const STEAM_CHARS: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
192
193#[cfg(test)]
194mod tests {
195    use super::Token;
196    use crate::Algorithm;
197
198    /// While 5..=8 is typical, we test with 3 digits for brevity.
199    const DIGITS: u8 = 3;
200    /// Tests are written to exhaustively search under the modular arithmetic of the token.
201    /// This caps the number of tokens to test just to avoid tests running for extremely long periods of time.
202    const MAX_TOKEN: u32 = 1_000_000;
203    /// We exhaustively test against all algorithms.
204    const ALL_ALGORITHMS: &[Algorithm] = &[
205        Algorithm::SHA1,
206        Algorithm::SHA256,
207        Algorithm::SHA512,
208        #[cfg(feature = "steam")]
209        Algorithm::Steam,
210    ];
211
212    /// Tests that all 5 and 6 digit tokens for all algorithms can
213    /// be formatted as a string and then retrieved as the same token through parsing.
214    ///
215    /// Also ensures [`Display`](core::fmt::Display) and [`Debug`](core::fmt::Debug)
216    /// formatting are equivalent.
217    #[test]
218    fn formatting_round_trip() {
219        for &alg in ALL_ALGORITHMS {
220            let digits = DIGITS;
221            let modulo = Token::modulo(alg, digits);
222            for value in 0..modulo.min(MAX_TOKEN) {
223                let token = Token::new(alg, digits, value);
224
225                let formatted = format!("{token}");
226                let re_parsed = Token::try_from_formatted_string(alg, digits, &formatted);
227                assert_eq!(
228                    Some(&token),
229                    re_parsed.as_ref(),
230                    "{formatted} could not be re-parsed!"
231                );
232
233                let debug_formatted = format!("{token:?}");
234                assert_eq!(
235                    formatted, debug_formatted,
236                    "debug and display formatting should be equivalent!"
237                );
238            }
239        }
240    }
241
242    /// Exhaustively tests that the highest bit is irrelevant.
243    #[test]
244    fn highest_bit_irrelevant() {
245        for &alg in ALL_ALGORITHMS {
246            let digits = DIGITS;
247            let modulo = Token::modulo(alg, digits);
248            for value in 0..modulo.min(MAX_TOKEN) {
249                let token = Token::new(alg, digits, value);
250                let token_with_high_bit = Token::new(alg, digits, value | 0x8000_0000);
251                let token_without_high_bit = Token::new(alg, digits, value & !0x8000_0000);
252
253                assert_eq!(
254                    token, token_with_high_bit,
255                    "setting high-bit made a difference when it shouldn't!"
256                );
257                assert_eq!(
258                    token, token_without_high_bit,
259                    "resetting high-bit made a difference when it shouldn't!"
260                );
261            }
262        }
263    }
264
265    /// Tests that the modularity of a token's value is respected.
266    #[test]
267    fn modular_arithmetic() {
268        for &alg in ALL_ALGORITHMS {
269            let digits = DIGITS;
270            let modulo = Token::modulo(alg, digits);
271            for value in 0..modulo.min(MAX_TOKEN) {
272                let token = Token::new(alg, digits, value);
273                for order in 1..5 {
274                    let token_next_mod = Token::new(alg, digits, value + order * modulo);
275                    assert_eq!(
276                        token, token_next_mod,
277                        "tokens should be equivalent under their modulo!"
278                    );
279                }
280            }
281        }
282    }
283
284    /// Tests that [`Token::from_signature`] works as expected:
285    /// * Last byte used as an offset
286    /// * offset..offset + 4 treated as a big-endian [`u32`]
287    /// * Passed into [Token::new]
288    #[test]
289    fn from_signature() {
290        for &alg in ALL_ALGORITHMS {
291            let digits = DIGITS;
292            let modulo = Token::modulo(alg, digits);
293            for value in 0..modulo.min(MAX_TOKEN) {
294                for offset in 0..4 {
295                    let mut signature = [0; 8];
296                    *signature.last_mut().unwrap() = offset as u8;
297                    signature[offset..][..4].copy_from_slice(&value.to_be_bytes());
298
299                    assert_eq!(
300                        Token::new(alg, digits, value),
301                        Token::from_signature(alg, digits, &signature),
302                        "expected {signature:?} to be equivalent to {value}!"
303                    );
304                }
305            }
306        }
307    }
308
309    /// Ensure tokens with an invalid character-set fail to parse.
310    #[test]
311    fn parsing_failure() {
312        let invalid_token_for_sha1 = "abc123";
313        let token = Token::try_from_formatted_string(
314            Algorithm::SHA1,
315            invalid_token_for_sha1.len() as u8,
316            invalid_token_for_sha1,
317        );
318        assert_eq!(token, None);
319    }
320
321    /// `u32::from_str_radix` accepts a leading sign and would otherwise treat
322    /// e.g. "+8020" as equivalent to the token "08020". Non-digit characters,
323    /// including a leading `+`, must be rejected even when the length matches.
324    #[test]
325    fn parsing_rejects_non_digits() {
326        for non_digit in ["+8020", "-8020", " 8020", "80 20"] {
327            assert_eq!(
328                Token::try_from_formatted_string(Algorithm::SHA1, 5, non_digit),
329                None,
330                "expected \"{non_digit}\" to be rejected"
331            );
332        }
333
334        // A canonical all-digit token of the same length still parses.
335        assert!(Token::try_from_formatted_string(Algorithm::SHA1, 5, "08020").is_some());
336    }
337
338    /// "22222", causing [`check`](crate::Totp::check) to accept it.
339    #[test]
340    #[cfg(feature = "steam")]
341    fn steam_parsing_rejects_chars_outside_alphabet() {
342        for invalid in ["AAAAA", "2222A", "ZZZZZ", "2345I"] {
343            assert_eq!(
344                Token::try_from_formatted_string(Algorithm::Steam, 5, invalid),
345                None,
346                "expected \"{invalid}\" to be rejected"
347            );
348        }
349
350        // A token made only of alphabet characters still parses.
351        assert!(Token::try_from_formatted_string(Algorithm::Steam, 5, "22222").is_some());
352    }
353}