use core::fmt;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{HmacSha1, HmacSha256, HmacSha512, RandomError, fill_random};
use crate::encoding::{Base32DecodeError, base32_decode, base32_encode, hex_encode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TotpAlgorithm {
Sha1,
Sha256,
Sha512,
}
impl fmt::Display for TotpAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Sha1 => f.write_str("SHA1"),
Self::Sha256 => f.write_str("SHA256"),
Self::Sha512 => f.write_str("SHA512"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TotpConfig {
period: u64,
digits: u32,
algorithm: TotpAlgorithm,
skew: u64,
}
impl TotpConfig {
#[must_use]
pub fn new() -> Self {
Self {
period: 30,
digits: 6,
algorithm: TotpAlgorithm::Sha1,
skew: 1,
}
}
pub fn with_period(mut self, period: u64) -> Result<Self, TotpError> {
if period == 0 {
return Err(TotpError::InvalidPeriod);
}
self.period = period;
Ok(self)
}
pub fn with_digits(mut self, digits: u32) -> Result<Self, TotpError> {
if digits == 0 || digits > 9 {
return Err(TotpError::InvalidDigits);
}
self.digits = digits;
Ok(self)
}
#[must_use]
pub fn with_algorithm(mut self, algorithm: TotpAlgorithm) -> Self {
self.algorithm = algorithm;
self
}
fn validate(&self) -> Result<(), TotpError> {
if self.period == 0 {
return Err(TotpError::InvalidPeriod);
}
if self.digits == 0 || self.digits > 9 {
return Err(TotpError::InvalidDigits);
}
Ok(())
}
pub fn with_skew(mut self, skew: u64) -> Result<Self, TotpError> {
if skew > 2 {
return Err(TotpError::InvalidSkew);
}
self.skew = skew;
Ok(self)
}
#[must_use]
#[inline]
pub fn period(&self) -> u64 {
self.period
}
#[must_use]
#[inline]
pub fn digits(&self) -> u32 {
self.digits
}
#[must_use]
#[inline]
pub fn algorithm(&self) -> TotpAlgorithm {
self.algorithm
}
#[must_use]
#[inline]
pub fn skew(&self) -> u64 {
self.skew
}
}
impl Default for TotpConfig {
fn default() -> Self {
Self::new()
}
}
fn uri_encode(input: &str) -> String {
static HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(input.len());
for &b in input.as_bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_' || b == b'~' {
out.push(b as char);
} else {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0F) as usize] as char);
}
}
out
}
pub struct TotpSecret {
key: Zeroizing<Vec<u8>>,
}
impl TotpSecret {
pub fn generate(len: usize) -> Result<Self, TotpError> {
const MIN_SECRET_BYTES: usize = 16;
if len < MIN_SECRET_BYTES {
return Err(TotpError::SecretTooShort);
}
let mut buf = vec![0u8; len];
fill_random(&mut buf).map_err(TotpError::Random)?;
Ok(Self {
key: Zeroizing::new(buf),
})
}
pub fn from_base32(encoded: &str) -> Result<Self, TotpError> {
let bytes = base32_decode(encoded).map_err(TotpError::Base32)?;
Ok(Self {
key: Zeroizing::new(bytes),
})
}
#[must_use]
pub fn to_base32(&self) -> String {
base32_encode(&self.key).trim_end_matches('=').to_owned()
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.key
}
#[must_use]
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self {
key: Zeroizing::new(bytes),
}
}
#[must_use]
pub fn generate_uri(&self, issuer: &str, account: &str, config: &TotpConfig) -> String {
let secret_b32 = self.to_base32();
let enc_issuer = uri_encode(issuer);
let enc_account = uri_encode(account);
format!(
"otpauth://totp/{enc_issuer}:{enc_account}?secret={secret_b32}&issuer={enc_issuer}&algorithm={alg}&digits={digits}&period={period}",
enc_issuer = enc_issuer,
enc_account = enc_account,
secret_b32 = secret_b32,
alg = config.algorithm,
digits = config.digits,
period = config.period,
)
}
}
impl fmt::Debug for TotpSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TotpSecret").finish_non_exhaustive()
}
}
impl Clone for TotpSecret {
fn clone(&self) -> Self {
Self {
key: Zeroizing::new(self.key.to_vec()),
}
}
}
fn hotp(key: &[u8], counter: u64, algorithm: TotpAlgorithm, digits: u32) -> String {
let counter_bytes = counter.to_be_bytes();
let hs: Vec<u8> = match algorithm {
TotpAlgorithm::Sha1 => HmacSha1::mac(key, &counter_bytes).to_vec(),
TotpAlgorithm::Sha256 => HmacSha256::mac(key, &counter_bytes).to_vec(),
TotpAlgorithm::Sha512 => HmacSha512::mac(key, &counter_bytes).to_vec(),
};
let offset = (hs[hs.len() - 1] & 0x0F) as usize;
let bin_code = (u32::from(hs[offset]) & 0x7F) << 24
| u32::from(hs[offset + 1]) << 16
| u32::from(hs[offset + 2]) << 8
| u32::from(hs[offset + 3]);
let modulus = 10u32.pow(digits);
let otp = bin_code % modulus;
format!("{otp:0>width$}", width = digits as usize)
}
pub fn totp_generate(
secret: &TotpSecret,
time_secs: u64,
config: &TotpConfig,
) -> Result<String, TotpError> {
config.validate()?;
let t = time_secs / config.period;
Ok(hotp(secret.as_bytes(), t, config.algorithm, config.digits))
}
pub fn totp_verify(
secret: &TotpSecret,
code: &str,
time_secs: u64,
config: &TotpConfig,
) -> Result<bool, TotpError> {
Ok(totp_verify_step(secret, code, time_secs, config)?.is_some())
}
pub fn totp_verify_step(
secret: &TotpSecret,
code: &str,
time_secs: u64,
config: &TotpConfig,
) -> Result<Option<u64>, TotpError> {
config.validate()?;
let t = time_secs / config.period;
let mut matched: Option<u64> = None;
let start = t.saturating_sub(config.skew);
let end = t.saturating_add(config.skew);
for step in start..=end {
let expected = hotp(secret.as_bytes(), step, config.algorithm, config.digits);
if crate::crypto::constant_time::constant_time_eq(expected.as_bytes(), code.as_bytes()) {
matched = Some(step);
}
}
Ok(matched)
}
const MIN_RECOVERY_CODE_BYTES: usize = 8;
pub fn generate_recovery_codes(
count: usize,
length: usize,
) -> Result<Vec<Zeroizing<String>>, TotpError> {
if length < MIN_RECOVERY_CODE_BYTES {
return Err(TotpError::RecoveryCodeTooShort);
}
let mut codes = Vec::with_capacity(count);
for _ in 0..count {
let mut buf = vec![0u8; length];
fill_random(&mut buf).map_err(TotpError::Random)?;
let hex = Zeroizing::new(hex_encode(&buf));
crate::crypto::zeroize::zeroize(&mut buf);
let mid = hex.len() / 2;
let code = Zeroizing::new(format!("{}-{}", &hex[..mid], &hex[mid..]));
codes.push(code);
}
Ok(codes)
}
#[derive(Debug)]
pub enum TotpError {
Random(RandomError),
Base32(Base32DecodeError),
InvalidDigits,
InvalidPeriod,
InvalidSkew,
SecretTooShort,
RecoveryCodeTooShort,
}
impl fmt::Display for TotpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Random(e) => write!(f, "totp: {e}"),
Self::Base32(e) => write!(f, "totp: {e}"),
Self::InvalidDigits => f.write_str("totp: invalid digit count (must be 1-9)"),
Self::InvalidPeriod => f.write_str("totp: invalid period (must be non-zero)"),
Self::InvalidSkew => f.write_str("totp: invalid skew (must be <= 2)"),
Self::SecretTooShort => {
f.write_str("totp: secret too short (must be at least 16 bytes / 128 bits)")
}
Self::RecoveryCodeTooShort => {
f.write_str("totp: recovery code too short (must be at least 8 bytes / 64 bits)")
}
}
}
}
impl std::error::Error for TotpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Random(e) => Some(e),
Self::Base32(e) => Some(e),
Self::InvalidDigits
| Self::InvalidPeriod
| Self::InvalidSkew
| Self::SecretTooShort
| Self::RecoveryCodeTooShort => None,
}
}
}
impl Clone for TotpError {
fn clone(&self) -> Self {
match self {
Self::Random(e) => Self::Random(e.clone()),
Self::Base32(e) => Self::Base32(e.clone()),
Self::InvalidDigits => Self::InvalidDigits,
Self::InvalidPeriod => Self::InvalidPeriod,
Self::InvalidSkew => Self::InvalidSkew,
Self::SecretTooShort => Self::SecretTooShort,
Self::RecoveryCodeTooShort => Self::RecoveryCodeTooShort,
}
}
}
impl PartialEq for TotpError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Random(a), Self::Random(b)) => a == b,
(Self::Base32(a), Self::Base32(b)) => a == b,
(Self::InvalidDigits, Self::InvalidDigits)
| (Self::InvalidPeriod, Self::InvalidPeriod)
| (Self::InvalidSkew, Self::InvalidSkew)
| (Self::SecretTooShort, Self::SecretTooShort)
| (Self::RecoveryCodeTooShort, Self::RecoveryCodeTooShort) => true,
_ => false,
}
}
}
impl Eq for TotpError {}
#[cfg(test)]
mod tests {
use super::*;
fn rfc6238_sha1_config() -> TotpConfig {
TotpConfig {
period: 30,
digits: 8,
algorithm: TotpAlgorithm::Sha1,
skew: 0,
}
}
fn rfc6238_sha1_secret() -> TotpSecret {
TotpSecret::from_bytes(b"12345678901234567890".to_vec())
}
#[test]
fn rfc6238_sha1_time_59() {
let secret = rfc6238_sha1_secret();
let config = rfc6238_sha1_config();
let code = totp_generate(&secret, 59, &config).unwrap();
assert_eq!(code, "94287082");
}
#[test]
fn rfc6238_sha1_time_1111111109() {
let secret = rfc6238_sha1_secret();
let config = rfc6238_sha1_config();
let code = totp_generate(&secret, 1_111_111_109, &config).unwrap();
assert_eq!(code, "07081804");
}
#[test]
fn rfc6238_sha1_time_1234567890() {
let secret = rfc6238_sha1_secret();
let config = rfc6238_sha1_config();
let code = totp_generate(&secret, 1_234_567_890, &config).unwrap();
assert_eq!(code, "89005924");
}
#[test]
fn rfc6238_sha256_vectors() {
let secret = TotpSecret::from_bytes(b"12345678901234567890123456789012".to_vec());
let config = TotpConfig {
period: 30,
digits: 8,
algorithm: TotpAlgorithm::Sha256,
skew: 0,
};
assert_eq!(totp_generate(&secret, 59, &config).unwrap(), "46119246");
assert_eq!(
totp_generate(&secret, 1_111_111_109, &config).unwrap(),
"68084774"
);
assert_eq!(
totp_generate(&secret, 1_234_567_890, &config).unwrap(),
"91819424"
);
}
#[test]
fn rfc6238_sha512_vectors() {
let secret = TotpSecret::from_bytes(
b"1234567890123456789012345678901234567890123456789012345678901234".to_vec(),
);
let config = TotpConfig {
period: 30,
digits: 8,
algorithm: TotpAlgorithm::Sha512,
skew: 0,
};
assert_eq!(totp_generate(&secret, 59, &config).unwrap(), "90693936");
assert_eq!(
totp_generate(&secret, 1_111_111_109, &config).unwrap(),
"25091201"
);
assert_eq!(
totp_generate(&secret, 1_234_567_890, &config).unwrap(),
"93441116"
);
}
#[test]
fn verify_step_returns_matched_step_for_replay_guard() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig {
period: 30,
digits: 8,
algorithm: TotpAlgorithm::Sha1,
skew: 1,
};
let code = totp_generate(&secret, 59, &config).unwrap();
assert_eq!(
totp_verify_step(&secret, &code, 59, &config).unwrap(),
Some(1)
);
assert_eq!(
totp_verify_step(&secret, "00000000", 59, &config).unwrap(),
None
);
}
#[test]
fn verify_with_skew() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig {
period: 30,
digits: 8,
algorithm: TotpAlgorithm::Sha1,
skew: 1,
};
let code = totp_generate(&secret, 59, &config).unwrap();
assert!(totp_verify(&secret, &code, 59, &config).unwrap());
assert!(totp_verify(&secret, &code, 59 + 30, &config).unwrap());
assert!(totp_verify(&secret, &code, 29, &config).unwrap());
assert!(!totp_verify(&secret, &code, 59 + 60, &config).unwrap());
}
#[test]
fn verify_rejects_wrong_length_code() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig::default(); assert_eq!(
totp_verify_step(&secret, "1234", 59, &config).unwrap(),
None,
);
assert_eq!(
totp_verify_step(&secret, "12345678", 59, &config).unwrap(),
None,
);
assert!(!totp_verify(&secret, "", 59, &config).unwrap());
}
#[test]
fn secret_base32_round_trip() {
let secret = TotpSecret::from_bytes(b"12345678901234567890".to_vec());
let b32 = secret.to_base32();
let decoded = TotpSecret::from_base32(&b32).unwrap();
assert_eq!(decoded.as_bytes(), secret.as_bytes());
}
#[test]
fn generate_uri_format() {
let secret = TotpSecret::from_bytes(b"12345678901234567890".to_vec());
let config = TotpConfig::default();
let uri = secret.generate_uri("Example", "user@example.com", &config);
assert!(uri.starts_with("otpauth://totp/Example:user%40example.com?secret="));
assert!(uri.contains("&issuer=Example"));
assert!(uri.contains("&algorithm=SHA1"));
assert!(uri.contains("&digits=6"));
assert!(uri.contains("&period=30"));
let secret_part = uri
.split("secret=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
assert!(!secret_part.contains('='));
}
#[test]
fn recovery_codes_format() {
let codes = generate_recovery_codes(8, 8).unwrap();
assert_eq!(codes.len(), 8);
for code in &codes {
let code: &str = code;
assert_eq!(code.len(), 17);
assert_eq!(&code[8..9], "-");
assert!(code[..8].chars().all(|c| c.is_ascii_hexdigit()));
assert!(code[9..].chars().all(|c| c.is_ascii_hexdigit()));
}
}
#[test]
fn recovery_codes_reject_below_entropy_floor() {
assert!(matches!(
generate_recovery_codes(5, 0),
Err(TotpError::RecoveryCodeTooShort)
));
assert!(matches!(
generate_recovery_codes(5, 7),
Err(TotpError::RecoveryCodeTooShort)
));
assert!(generate_recovery_codes(1, 8).is_ok());
}
#[test]
fn recovery_codes_unique() {
let codes = generate_recovery_codes(10, 8).unwrap();
for (i, a) in codes.iter().enumerate() {
for (j, b) in codes.iter().enumerate() {
if i != j {
assert_ne!(a.as_str(), b.as_str(), "recovery codes should be unique");
}
}
}
}
#[test]
fn invalid_period_zero() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig {
period: 0,
..TotpConfig::default()
};
assert!(matches!(
totp_generate(&secret, 59, &config),
Err(TotpError::InvalidPeriod)
));
}
#[test]
fn invalid_digits_zero() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig {
digits: 0,
..TotpConfig::default()
};
assert!(matches!(
totp_generate(&secret, 59, &config),
Err(TotpError::InvalidDigits)
));
}
#[test]
fn invalid_digits_ten() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig {
digits: 10,
..Default::default()
};
assert!(matches!(
totp_generate(&secret, 59, &config),
Err(TotpError::InvalidDigits)
));
}
#[test]
fn invalid_base32_secret() {
assert!(matches!(
TotpSecret::from_base32("!!!invalid!!!"),
Err(TotpError::Base32(_))
));
}
#[test]
fn secret_debug_does_not_leak_key() {
let secret = TotpSecret::from_bytes(b"super-secret-key".to_vec());
let debug = format!("{secret:?}");
assert!(debug.contains("TotpSecret"));
assert!(!debug.contains("super"));
assert!(!debug.contains("secret-key"));
}
#[test]
fn error_display_messages() {
let err = TotpError::InvalidDigits;
assert_eq!(err.to_string(), "totp: invalid digit count (must be 1-9)");
let err = TotpError::InvalidPeriod;
assert_eq!(err.to_string(), "totp: invalid period (must be non-zero)");
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(TotpError::InvalidDigits);
assert!(err.source().is_none());
let _ = err.to_string();
}
#[test]
fn default_config_6_digit() {
let secret = rfc6238_sha1_secret();
let config = TotpConfig::default();
let code = totp_generate(&secret, 59, &config).unwrap();
assert_eq!(code.len(), 6);
assert!(totp_verify(&secret, &code, 59, &config).unwrap());
}
}