use crate::Algorithm;
use core::fmt;
#[derive(Clone, Eq)]
#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
pub struct Token {
#[cfg_attr(feature = "zeroize", zeroize(skip))]
algorithm: Algorithm,
#[cfg_attr(feature = "zeroize", zeroize(skip))]
digits: u8,
value: u32,
}
impl Token {
pub(crate) const fn new(algorithm: Algorithm, digits: u8, value: u32) -> Self {
Self {
algorithm,
digits,
value: (value & 0x7FFF_FFFF) % Self::modulo(algorithm, digits),
}
}
pub(crate) const fn from_signature(algorithm: Algorithm, digits: u8, signature: &[u8]) -> Self {
let last = *signature.last().unwrap();
let offset = (last & 0xF) as usize;
let value = u32::from_be_bytes([
signature[offset],
signature[offset + 1],
signature[offset + 2],
signature[offset + 3],
]);
Self::new(algorithm, digits, value)
}
pub(crate) const fn try_from_formatted_string(
algorithm: Algorithm,
digits: u8,
string: &str,
) -> Option<Self> {
if string.len() != digits as usize {
return None;
}
let value = match algorithm {
Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => {
let bytes = string.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_digit() {
return None;
}
i += 1;
}
match u32::from_str_radix(string, 10) {
Ok(value) => value,
Err(_) => return None,
}
}
#[cfg(feature = "steam")]
Algorithm::Steam => {
let radix = STEAM_CHARS.len();
let mut value = 0;
let mut place = 1;
let mut bytes = string.as_bytes();
while let [byte, rest @ ..] = bytes {
let mut i = 0;
let mut digits = STEAM_CHARS;
let index = loop {
match digits {
[x, _rest @ ..] if *x == *byte => break i,
[_, rest @ ..] => {
i += 1;
digits = rest;
}
[] => return None,
}
};
value += index * place;
bytes = rest;
place *= radix;
}
value as u32
}
};
Some(Self::new(algorithm, digits, value))
}
const fn modulo(algorithm: Algorithm, digits: u8) -> u32 {
match algorithm {
Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => 10_u32.checked_pow(digits as u32)
.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."),
#[cfg(feature = "steam")]
Algorithm::Steam => (STEAM_CHARS.len() as u32).checked_pow(digits as u32)
.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."),
}
}
}
impl PartialEq for Token {
fn eq(&self, other: &Self) -> bool {
constant_time_eq::constant_time_eq_n(&self.value.to_ne_bytes(), &other.value.to_ne_bytes())
&& self.algorithm == other.algorithm
&& self.digits == other.digits
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
<Self as fmt::Display>::fmt(self, f)
}
}
impl fmt::Display for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.algorithm {
Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => write!(
f,
"{1:00$}",
self.digits.into(),
self.value % 10_u32.checked_pow(self.digits.into())
.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."),
),
#[cfg(feature = "steam")]
Algorithm::Steam => {
use core::fmt::Write as _;
if self.digits >= 7 {
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.")
}
let chars = (0..self.digits).scan(self.value, |value, _| {
let digit = *value as usize % STEAM_CHARS.len();
*value /= STEAM_CHARS.len() as u32;
Some(char::from(STEAM_CHARS[digit]))
});
for c in chars {
f.write_char(c)?;
}
Ok(())
}
}
}
}
#[cfg(feature = "steam")]
const STEAM_CHARS: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
#[cfg(test)]
mod tests {
use super::Token;
use crate::Algorithm;
const DIGITS: u8 = 3;
const MAX_TOKEN: u32 = 1_000_000;
const ALL_ALGORITHMS: &[Algorithm] = &[
Algorithm::SHA1,
Algorithm::SHA256,
Algorithm::SHA512,
#[cfg(feature = "steam")]
Algorithm::Steam,
];
#[test]
fn formatting_round_trip() {
for &alg in ALL_ALGORITHMS {
let digits = DIGITS;
let modulo = Token::modulo(alg, digits);
for value in 0..modulo.min(MAX_TOKEN) {
let token = Token::new(alg, digits, value);
let formatted = format!("{token}");
let re_parsed = Token::try_from_formatted_string(alg, digits, &formatted);
assert_eq!(
Some(&token),
re_parsed.as_ref(),
"{formatted} could not be re-parsed!"
);
let debug_formatted = format!("{token:?}");
assert_eq!(
formatted, debug_formatted,
"debug and display formatting should be equivalent!"
);
}
}
}
#[test]
fn highest_bit_irrelevant() {
for &alg in ALL_ALGORITHMS {
let digits = DIGITS;
let modulo = Token::modulo(alg, digits);
for value in 0..modulo.min(MAX_TOKEN) {
let token = Token::new(alg, digits, value);
let token_with_high_bit = Token::new(alg, digits, value | 0x8000_0000);
let token_without_high_bit = Token::new(alg, digits, value & !0x8000_0000);
assert_eq!(
token, token_with_high_bit,
"setting high-bit made a difference when it shouldn't!"
);
assert_eq!(
token, token_without_high_bit,
"resetting high-bit made a difference when it shouldn't!"
);
}
}
}
#[test]
fn modular_arithmetic() {
for &alg in ALL_ALGORITHMS {
let digits = DIGITS;
let modulo = Token::modulo(alg, digits);
for value in 0..modulo.min(MAX_TOKEN) {
let token = Token::new(alg, digits, value);
for order in 1..5 {
let token_next_mod = Token::new(alg, digits, value + order * modulo);
assert_eq!(
token, token_next_mod,
"tokens should be equivalent under their modulo!"
);
}
}
}
}
#[test]
fn from_signature() {
for &alg in ALL_ALGORITHMS {
let digits = DIGITS;
let modulo = Token::modulo(alg, digits);
for value in 0..modulo.min(MAX_TOKEN) {
for offset in 0..4 {
let mut signature = [0; 8];
*signature.last_mut().unwrap() = offset as u8;
signature[offset..][..4].copy_from_slice(&value.to_be_bytes());
assert_eq!(
Token::new(alg, digits, value),
Token::from_signature(alg, digits, &signature),
"expected {signature:?} to be equivalent to {value}!"
);
}
}
}
}
#[test]
fn parsing_failure() {
let invalid_token_for_sha1 = "abc123";
let token = Token::try_from_formatted_string(
Algorithm::SHA1,
invalid_token_for_sha1.len() as u8,
invalid_token_for_sha1,
);
assert_eq!(token, None);
}
#[test]
fn parsing_rejects_non_digits() {
for non_digit in ["+8020", "-8020", " 8020", "80 20"] {
assert_eq!(
Token::try_from_formatted_string(Algorithm::SHA1, 5, non_digit),
None,
"expected \"{non_digit}\" to be rejected"
);
}
assert!(Token::try_from_formatted_string(Algorithm::SHA1, 5, "08020").is_some());
}
#[test]
#[cfg(feature = "steam")]
fn steam_parsing_rejects_chars_outside_alphabet() {
for invalid in ["AAAAA", "2222A", "ZZZZZ", "2345I"] {
assert_eq!(
Token::try_from_formatted_string(Algorithm::Steam, 5, invalid),
None,
"expected \"{invalid}\" to be rejected"
);
}
assert!(Token::try_from_formatted_string(Algorithm::Steam, 5, "22222").is_some());
}
}