1use crate::Algorithm;
2use core::fmt;
3
4#[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 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 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 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#[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 const DIGITS: u8 = 3;
200 const MAX_TOKEN: u32 = 1_000_000;
203 const ALL_ALGORITHMS: &[Algorithm] = &[
205 Algorithm::SHA1,
206 Algorithm::SHA256,
207 Algorithm::SHA512,
208 #[cfg(feature = "steam")]
209 Algorithm::Steam,
210 ];
211
212 #[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 #[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 #[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 #[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 #[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 #[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 assert!(Token::try_from_formatted_string(Algorithm::SHA1, 5, "08020").is_some());
336 }
337
338 #[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 assert!(Token::try_from_formatted_string(Algorithm::Steam, 5, "22222").is_some());
352 }
353}