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, Default)]
pub enum Digits {
#[default]
Six,
Seven,
Eight,
}
impl Digits {
#[must_use]
pub const fn count(self) -> u32 {
match self {
Self::Six => 6,
Self::Seven => 7,
Self::Eight => 8,
}
}
pub fn parse(value: &str) -> Result<Self> {
match value {
"6" => Ok(Self::Six),
"7" => Ok(Self::Seven),
"8" => Ok(Self::Eight),
_ => Err(Error::UnsupportedDigits {
value: truncate_for_error(value),
}),
}
}
#[must_use]
const fn modulus(self) -> u32 {
match self {
Self::Six => 1_000_000,
Self::Seven => 10_000_000,
Self::Eight => 100_000_000,
}
}
}
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 = "本类型的不变量保证非空,`is_empty` 恒为 false,提供它反而误导"
)]
#[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 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:?} 应被拒绝"
);
}
}
#[test]
fn truncate_for_error_handles_multibyte_boundary() {
let long = "算".repeat(20);
let text = truncate_for_error(&long);
assert!(text.ends_with('…'), "应带省略号:{text}");
assert!(long.starts_with(text.trim_end_matches('…')));
}
#[test]
fn digits_parsing_accepts_only_6_7_8() {
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);
for bad in ["5", "9", "0", "06", "", "six", "99999999999999999999"] {
assert!(
matches!(Digits::parse(bad), Err(Error::UnsupportedDigits { .. })),
"{bad:?} 应被拒绝"
);
}
}
#[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("非空");
let text = format!("{secret:?}");
assert!(text.contains("redacted"), "应标注脱敏:{text}");
for leak in ["dead", "beef", "222", "173"] {
assert!(!text.to_lowercase().contains(leak), "泄露 {leak}:{text}");
}
}
#[test]
fn period_zero_is_rejected() {
let secret = OtpSecret::from_bytes(vec![1, 2, 3]).expect("非空");
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("非空短种子应被接受");
assert!(secret.is_shorter_than_recommended(Algorithm::Sha1));
assert_eq!(secret.len(), 4);
let long = OtpSecret::from_bytes(vec![0_u8; 20]).expect("非空");
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("非空");
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("不越界");
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), "偏移 {expected_offset}");
}
}
#[test]
fn drift_window_rejects_out_of_window() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("非空");
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,
"窗口外不应命中"
);
}
#[test]
fn drift_window_handles_counter_underflow() {
let secret = OtpSecret::from_bytes(vec![7_u8; 20]).expect("非空");
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), "当前步应命中");
}
#[test]
fn steam_codes_are_five_chars_from_steam_alphabet() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("非空");
let code =
steam_guard_code(&secret, 1_700_000_000, 30, Algorithm::Sha1).expect("period 非零");
assert_eq!(code.chars().count(), STEAM_DIGITS, "应为 5 位:{code}");
for ch in code.chars() {
assert!(
STEAM_ALPHABET.contains(&(ch as u8)),
"字符 {ch:?} 不在 Steam 字母表内:{code}"
);
}
}
#[test]
fn steam_codes_change_across_steps() {
let secret = OtpSecret::from_bytes((0..20).collect::<Vec<u8>>()).expect("非空");
let a = steam_guard_code(&secret, 1_700_000_000, 30, Algorithm::Sha1).expect("period 非零");
let b = steam_guard_code(&secret, 1_700_000_030, 30, Algorithm::Sha1).expect("period 非零");
assert_ne!(a, b, "相邻时间步应产出不同 Steam 码");
}
#[test]
fn internal_expose_matches_public_secret() {
let secret = OtpSecret::from_bytes(vec![1, 2, 3]).expect("非空");
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), "擦除后应全为零");
}
#[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]);
}
}