use sha1::Sha1;
use sha2::{Sha256, Sha512};
use subtle::ConstantTimeEq;
use crate::{Error, ExposeSecret, Result, secret::SecretBox};
#[cfg(test)]
use secrecy::zeroize::Zeroize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Algorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
impl Algorithm {
#[must_use]
pub const fn recommended_secret_len(self) -> usize {
match self {
Self::Sha1 => 20,
Self::Sha256 => 32,
Self::Sha512 => 64,
}
}
pub fn parse(value: &str) -> Result<Self> {
let mut buf = [0_u8; 8];
let bytes = value.as_bytes();
if bytes.len() > buf.len() {
return Err(Error::UnsupportedAlgorithm {
name: truncate_for_error(value),
});
}
for (slot, &byte) in buf.iter_mut().zip(bytes) {
*slot = byte.to_ascii_uppercase();
}
let normalized = &buf[..bytes.len()];
match normalized {
b"SHA1" => Ok(Self::Sha1),
b"SHA256" => Ok(Self::Sha256),
b"SHA512" => Ok(Self::Sha512),
_ => Err(Error::UnsupportedAlgorithm {
name: truncate_for_error(value),
}),
}
}
}
fn truncate_for_error(value: &str) -> String {
const MAX: usize = 16;
if value.len() <= MAX {
return value.into();
}
let mut end = MAX;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
let mut out = String::from(&value[..end]);
out.push('…');
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Digits(u8);
impl Digits {
pub const FIVE: Self = Self(5);
pub const SIX: Self = Self(6);
pub const SEVEN: Self = Self(7);
pub const EIGHT: Self = Self(8);
pub const MIN: u8 = 1;
pub const MAX: u8 = 9;
pub fn new(count: u8) -> Result<Self> {
match count {
0 => Err(Error::DigitsZero),
Self::MIN..=Self::MAX => Ok(Self(count)),
count => Err(Error::DigitsTooMany { count }),
}
}
#[must_use]
pub const fn count(self) -> u32 {
self.0 as u32
}
#[must_use]
pub const fn as_byte(self) -> u8 {
self.0
}
pub fn parse(value: &str) -> Result<Self> {
if let [digit @ b'1'..=b'9'] = value.as_bytes() {
return Ok(Self(digit - b'0'));
}
if let Ok(count) = value.parse::<u8>()
&& count.to_string() == value
{
return Self::new(count);
}
Err(Error::UnsupportedDigits {
value: truncate_for_error(value),
})
}
#[must_use]
pub const fn modulus(self) -> u32 {
let mut value = 1_u32;
let mut remaining = self.0;
while remaining > 0 {
value *= 10;
remaining -= 1;
}
value
}
}
impl Default for Digits {
fn default() -> Self {
Self::SIX
}
}
impl TryFrom<u8> for Digits {
type Error = Error;
fn try_from(count: u8) -> Result<Self> {
Self::new(count)
}
}
pub struct OtpSecret {
bytes: SecretBox<[u8]>,
}
impl OtpSecret {
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.is_empty() {
return Err(Error::SecretEmpty);
}
Ok(Self {
bytes: bytes.into(),
})
}
pub fn from_base32(encoded: &str) -> Result<Self> {
let decoded = crate::base32::decode(encoded)?;
Self::from_bytes(decoded.expose_secret().to_vec())
}
#[expect(
clippy::len_without_is_empty,
reason = "the invariants of this type guarantee it is non-empty, so `is_empty` would always be false and offering it would be misleading"
)]
#[must_use]
pub fn len(&self) -> usize {
self.bytes.expose_secret().len()
}
#[must_use]
pub fn is_shorter_than_recommended(&self, algorithm: Algorithm) -> bool {
self.len() < algorithm.recommended_secret_len()
}
pub(crate) fn expose(&self) -> &[u8] {
self.bytes.expose_secret()
}
#[must_use]
pub fn to_base32(&self) -> String {
let encoded = crate::base32::encode(self.expose());
encoded.trim_end_matches('=').to_owned()
}
}
impl core::fmt::Debug for OtpSecret {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("OtpSecret")
.field("len", &self.len())
.field("bytes", &"<redacted>")
.finish()
}
}
impl PartialEq for OtpSecret {
fn eq(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq as _;
self.expose().ct_eq(other.expose()).into()
}
}
impl Eq for OtpSecret {}
#[must_use]
pub const fn recommended_min_secret_len(algorithm: Algorithm) -> usize {
algorithm.recommended_secret_len()
}
fn hmac_truncate(secret: &OtpSecret, algorithm: Algorithm, message: &[u8]) -> u32 {
use hmac::digest::common::BlockSizeUser;
use hmac::digest::{Digest, KeyInit, Mac};
fn digest_with<D>(key: &[u8], message: &[u8]) -> Option<Vec<u8>>
where
D: Digest + BlockSizeUser,
{
let mut mac = <hmac::SimpleHmac<D> as KeyInit>::new_from_slice(key).ok()?;
Mac::update(&mut mac, message);
Some(Mac::finalize(mac).into_bytes().to_vec())
}
let digest = match algorithm {
Algorithm::Sha1 => digest_with::<Sha1>(secret.expose(), message),
Algorithm::Sha256 => digest_with::<Sha256>(secret.expose(), message),
Algorithm::Sha512 => digest_with::<Sha512>(secret.expose(), message),
};
let Some(bytes) = digest else {
return 0;
};
let Some(last) = bytes.last() else {
return 0;
};
let offset = usize::from(last & 0x0f);
if offset + 4 > bytes.len() {
return 0;
}
let truncated = &bytes[offset..offset + 4];
let value = truncated
.iter()
.fold(0_u32, |acc, &b| (acc << 8) | u32::from(b));
value & 0x7fff_ffff
}
#[must_use]
pub fn hotp(secret: &OtpSecret, counter: u64, algorithm: Algorithm, digits: Digits) -> u32 {
let truncated = hmac_truncate(secret, algorithm, &counter.to_be_bytes());
truncated % digits.modulus()
}
pub fn totp(
secret: &OtpSecret,
unix_time: u64,
period: u64,
algorithm: Algorithm,
digits: Digits,
) -> Result<u32> {
if period == 0 {
return Err(Error::InvalidPeriod);
}
Ok(hotp(secret, unix_time / period, algorithm, digits))
}
#[must_use]
pub fn format_code(value: u32, digits: Digits) -> String {
format!("{:0width$}", value, width = digits.count() as usize)
}
#[must_use]
pub fn hotp_code(secret: &OtpSecret, counter: u64, algorithm: Algorithm, digits: Digits) -> String {
format_code(hotp(secret, counter, algorithm, digits), digits)
}
pub fn totp_code(
secret: &OtpSecret,
unix_time: u64,
period: u64,
algorithm: Algorithm,
digits: Digits,
) -> Result<String> {
Ok(format_code(
totp(secret, unix_time, period, algorithm, digits)?,
digits,
))
}
#[must_use]
pub fn verify_code(expected: &str, provided: &str) -> bool {
let a = expected.as_bytes();
let b = provided.as_bytes();
if a.len() != b.len() {
return false;
}
a.ct_eq(b).into()
}
#[must_use]
pub fn verify_with_drift(
secret: &OtpSecret,
provided_counter: u64,
window: u64,
algorithm: Algorithm,
digits: Digits,
provided_code: &str,
) -> Option<i64> {
fn matches(
secret: &OtpSecret,
counter: u64,
offset: i64,
algorithm: Algorithm,
digits: Digits,
provided_code: &str,
) -> bool {
let Some(candidate) = counter.checked_add_signed(offset) else {
return false;
};
let expected = hotp_code(secret, candidate, algorithm, digits);
verify_code(&expected, provided_code)
}
if matches(
secret,
provided_counter,
0,
algorithm,
digits,
provided_code,
) {
return Some(0);
}
for distance in 1..=window {
let Ok(distance_i64) = i64::try_from(distance) else {
break;
};
if matches(
secret,
provided_counter,
-distance_i64,
algorithm,
digits,
provided_code,
) {
return Some(-distance_i64);
}
if matches(
secret,
provided_counter,
distance_i64,
algorithm,
digits,
provided_code,
) {
return Some(distance_i64);
}
}
None
}
const STEAM_ALPHABET: &[u8; 26] = b"23456789BCDFGHJKMNPQRTVWXY";
pub const STEAM_DIGITS: usize = 5;
pub const STEAM_PERIOD: u64 = 30;
pub fn steam_guard_code(
secret: &OtpSecret,
unix_time: u64,
period: u64,
algorithm: Algorithm,
) -> Result<String> {
if period == 0 {
return Err(Error::InvalidPeriod);
}
let mut value = hmac_truncate(secret, algorithm, &(unix_time / period).to_be_bytes());
let mut out = String::with_capacity(STEAM_DIGITS);
for _ in 0..STEAM_DIGITS {
let index = (value % STEAM_ALPHABET.len() as u32) as usize;
out.push(char::from(STEAM_ALPHABET[index]));
value /= STEAM_ALPHABET.len() as u32;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExposeSecret;
#[test]
fn algorithm_parsing_is_case_insensitive() {
for (input, expected) in [
("SHA1", Algorithm::Sha1),
("sha1", Algorithm::Sha1),
("Sha1", Algorithm::Sha1),
("SHA256", Algorithm::Sha256),
("sha512", Algorithm::Sha512),
] {
assert_eq!(Algorithm::parse(input).expect(input), expected, "{input}");
}
}
#[test]
fn algorithm_parsing_rejects_unknown_and_oversized() {
for bad in ["MD5", "sha3", "", "SHA-1", "verylongalgorithmname"] {
assert!(
matches!(
Algorithm::parse(bad),
Err(Error::UnsupportedAlgorithm { .. })
),
"{bad:?} must be rejected"
);
}
}
#[test]
fn truncate_for_error_handles_multibyte_boundary() {
let long = "算".repeat(20);
let text = truncate_for_error(&long);
assert!(text.ends_with('…'), "must end with an ellipsis: {text}");
assert!(long.starts_with(text.trim_end_matches('…')));
}
#[test]
fn digits_parsing_accepts_every_canonical_count_from_1_to_9() {
for count in Digits::MIN..=Digits::MAX {
let text = count.to_string();
let parsed = Digits::parse(&text).expect("a canonical count");
assert_eq!(parsed.count(), u32::from(count), "{text}");
assert_eq!(Digits::new(count).expect("in range"), parsed, "{text}");
assert_eq!(parsed.as_byte(), count, "{text}");
}
assert_eq!(Digits::parse("6").expect("6"), Digits::SIX);
assert_eq!(Digits::parse("7").expect("7"), Digits::SEVEN);
assert_eq!(Digits::parse("8").expect("8"), Digits::EIGHT);
assert_eq!(Digits::default(), Digits::SIX);
}
#[test]
fn digits_reject_zero_ten_and_malformed_input() {
assert!(matches!(Digits::parse("0"), Err(Error::DigitsZero)));
assert!(matches!(Digits::new(0), Err(Error::DigitsZero)));
for too_many in ["10", "11", "99", "255"] {
assert!(
matches!(Digits::parse(too_many), Err(Error::DigitsTooMany { .. })),
"{too_many:?} must be rejected as too many"
);
}
assert!(matches!(
Digits::new(10),
Err(Error::DigitsTooMany { count: 10 })
));
for bad in [
"06",
"007",
"",
"six",
"-6",
" 6",
"6 ",
"99999999999999999999",
] {
assert!(
matches!(Digits::parse(bad), Err(Error::UnsupportedDigits { .. })),
"{bad:?} must be rejected as malformed"
);
}
}
#[test]
fn modulus_is_a_power_of_ten() {
let mut expected = 1_u32;
for count in Digits::MIN..=Digits::MAX {
expected *= 10;
let digits = Digits::new(count).expect("in range");
assert_eq!(digits.modulus(), expected, "count {count}");
}
}
#[test]
fn format_code_pads_to_exact_width() {
assert_eq!(format_code(0, Digits::SIX), "000000");
assert_eq!(format_code(1, Digits::SIX), "000001");
assert_eq!(format_code(123_456, Digits::SIX), "123456");
assert_eq!(format_code(0, Digits::EIGHT), "00000000");
assert_eq!(format_code(99_999_999, Digits::EIGHT), "99999999");
}
#[test]
fn empty_secret_is_rejected() {
assert!(matches!(
OtpSecret::from_bytes(Vec::new()),
Err(Error::SecretEmpty)
));
assert!(matches!(
OtpSecret::from_base32(""),
Err(Error::SecretTooShort { .. }) | Err(Error::SecretEmpty)
));
}
#[test]
fn secret_debug_never_leaks_bytes() {
let secret = OtpSecret::from_bytes(vec![0xde, 0xad, 0xbe, 0xef]).expect("non-empty");
let text = format!("{secret:?}");
assert!(
text.contains("redacted"),
"must be marked as redacted: {text}"
);
for leak in ["dead", "beef", "222", "173"] {
assert!(!text.to_lowercase().contains(leak), "leaks {leak}: {text}");
}
}
#[test]
fn period_zero_is_rejected() {
let secret = OtpSecret::from_bytes(vec![1, 2, 3]).expect("non-empty");
assert!(matches!(
totp(&secret, 0, 0, Algorithm::Sha1, Digits::SIX),
Err(Error::InvalidPeriod)
));
assert!(matches!(
totp_code(&secret, 0, 0, Algorithm::Sha1, Digits::SIX),
Err(Error::InvalidPeriod)
));
assert!(matches!(
steam_guard_code(&secret, 0, 0, Algorithm::Sha1),
Err(Error::InvalidPeriod)
));
}
#[test]
fn recommended_lengths_match_hash_output_sizes() {
assert_eq!(recommended_min_secret_len(Algorithm::Sha1), 20);
assert_eq!(recommended_min_secret_len(Algorithm::Sha256), 32);
assert_eq!(recommended_min_secret_len(Algorithm::Sha512), 64);
}
#[test]
fn short_secret_is_flagged_but_accepted() {
let secret = OtpSecret::from_bytes(vec![1, 2, 3, 4])
.expect("a non-empty short seed must be accepted");
assert!(secret.is_shorter_than_recommended(Algorithm::Sha1));
assert_eq!(secret.len(), 4);
let long = OtpSecret::from_bytes(vec![0_u8; 20]).expect("non-empty");
assert!(!long.is_shorter_than_recommended(Algorithm::Sha1));
}
#[test]
fn verify_code_is_length_sensitive_and_content_sensitive() {
assert!(verify_code("123456", "123456"));
assert!(!verify_code("123456", "123457"));
assert!(!verify_code("123456", "12345"));
assert!(!verify_code("123456", "1234567"));
assert!(!verify_code("123456", ""));
}
#[test]
fn drift_window_finds_neighbouring_steps() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("non-empty");
let period = 30_u64;
let now = 1_700_000_000_u64;
let counter = now / period;
for expected_offset in [-1_i64, 0, 1] {
let target = counter
.checked_add_signed(expected_offset)
.expect("no overflow");
let code = hotp_code(&secret, target, Algorithm::Sha1, Digits::SIX);
let got = verify_with_drift(&secret, counter, 1, Algorithm::Sha1, Digits::SIX, &code);
assert_eq!(got, Some(expected_offset), "offset {expected_offset}");
}
}
#[test]
fn drift_window_rejects_out_of_window() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("non-empty");
let counter = 1_700_000_000_u64 / 30;
let far = counter + 5;
let code = hotp_code(&secret, far, Algorithm::Sha1, Digits::SIX);
assert_eq!(
verify_with_drift(&secret, counter, 1, Algorithm::Sha1, Digits::SIX, &code),
None,
"must not match outside the window"
);
}
#[test]
fn drift_window_handles_counter_underflow() {
let secret = OtpSecret::from_bytes(vec![7_u8; 20]).expect("non-empty");
let code = hotp_code(&secret, 0, Algorithm::Sha1, Digits::SIX);
let got = verify_with_drift(&secret, 0, 3, Algorithm::Sha1, Digits::SIX, &code);
assert_eq!(got, Some(0), "the current step must match");
}
#[test]
fn steam_codes_are_five_chars_from_steam_alphabet() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("non-empty");
let code = steam_guard_code(&secret, 1_700_000_000, 30, Algorithm::Sha1)
.expect("period is non-zero");
assert_eq!(
code.chars().count(),
STEAM_DIGITS,
"must be 5 characters: {code}"
);
for ch in code.chars() {
assert!(
STEAM_ALPHABET.contains(&(ch as u8)),
"character {ch:?} is not in the Steam alphabet: {code}"
);
}
}
#[test]
fn steam_codes_change_across_steps() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("non-empty");
let a = steam_guard_code(&secret, 1_700_000_000, 30, Algorithm::Sha1)
.expect("period is non-zero");
let b = steam_guard_code(&secret, 1_700_000_030, 30, Algorithm::Sha1)
.expect("period is non-zero");
assert_ne!(
a, b,
"neighbouring time steps must produce different Steam codes"
);
}
#[test]
fn internal_expose_matches_public_secret() {
let secret = OtpSecret::from_bytes(vec![1, 2, 3]).expect("non-empty");
assert_eq!(secret.expose(), &[1, 2, 3]);
assert_eq!(secret.len(), 3);
}
#[test]
fn zeroize_is_available_for_intermediate_buffers() {
let mut buffer = vec![0xAA_u8; 16];
buffer.zeroize();
assert!(
buffer.iter().all(|&b| b == 0),
"must be all zeros after erasure"
);
}
#[test]
fn secret_box_expose_is_reachable() {
let boxed: SecretBox<[u8]> = vec![1_u8, 2, 3].into();
assert_eq!(boxed.expose_secret(), &[1, 2, 3]);
}
}