use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::Sha256;
use zeroize::Zeroizing;
type HmacSha256 = Hmac<Sha256>;
pub struct TOTPSecret {
secret: Zeroizing<Vec<u8>>,
period: u64,
digits: usize,
}
impl TOTPSecret {
#[must_use]
pub fn generate() -> Self {
let mut secret = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut secret);
Self {
secret: Zeroizing::new(secret),
period: 30,
digits: 6,
}
}
#[must_use]
pub fn from_bytes(secret: Vec<u8>) -> Self {
Self {
secret: Zeroizing::new(secret),
period: 30,
digits: 6,
}
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn now(&self) -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_secs();
self.code_at(timestamp)
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn code_at(&self, timestamp: u64) -> String {
let time_step = timestamp / self.period;
Self::generate_code(&self.secret, time_step, self.digits)
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn verify(&self, code: &str, tolerance: u64) -> bool {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_secs();
let time_step = timestamp / self.period;
for delta in 0..=tolerance {
if delta > 0 {
if time_step >= delta
&& code == Self::generate_code(&self.secret, time_step - delta, self.digits)
{
return true;
}
if code == Self::generate_code(&self.secret, time_step + delta, self.digits) {
return true;
}
} else {
if code == Self::generate_code(&self.secret, time_step, self.digits) {
return true;
}
}
}
false
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn to_base32(&self) -> String {
let mut bytes = vec![0u8; self.secret.len().div_ceil(5) * 8];
let bits = self.secret.len() * 8;
for (i, chunk) in self.secret.chunks(5).enumerate() {
let val: u64 = chunk.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
let shift = (5 - chunk.len()) * 8;
let shifted = val << shift;
for (j, b) in bytes[i * 8..i * 8 + 8].iter_mut().enumerate() {
let idx = (shifted >> ((7 - j) * 5)) & 0x1F;
*b = match idx {
0..=25 => b'A' + (idx as u8),
26..=31 => b'2' + ((idx - 26) as u8),
_ => 0,
};
}
}
let significant = bits.div_ceil(5);
String::from_utf8(bytes[..significant].to_vec()).unwrap_or_default()
}
#[allow(clippy::cast_possible_truncation)]
fn generate_code(secret: &[u8], time_step: u64, digits: usize) -> String {
let counter = time_step.to_be_bytes();
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
mac.update(&counter);
let result = mac.finalize().into_bytes();
let offset = (result[result.len() - 1] & 0x0F) as usize;
let code = u32::from(result[offset] & 0x7F) << 24
| u32::from(result[offset + 1]) << 16
| u32::from(result[offset + 2]) << 8
| u32::from(result[offset + 3]);
let modulo = 10u32.pow(digits as u32);
format!("{:0>width$}", code % modulo, width = digits)
}
}
impl core::fmt::Debug for TOTPSecret {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"TOTPSecret(redacted, period={}, digits={})",
self.period, self.digits
)
}
}
pub struct TwoFactorChallenge {
pub id: String,
pub completed: bool,
pub attempts_left: u32,
pub max_attempts: u32,
}
impl TwoFactorChallenge {
#[must_use]
pub fn new(id: String) -> Self {
Self {
id,
completed: false,
attempts_left: 3,
max_attempts: 3,
}
}
pub fn record_attempt(&mut self) -> bool {
if self.attempts_left == 0 {
return false;
}
self.attempts_left -= 1;
self.attempts_left > 0
}
pub fn complete(&mut self) {
self.completed = true;
self.attempts_left = 0;
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.completed && self.attempts_left > 0
}
}
pub struct BackupCodeSet {
codes: Vec<String>,
}
impl BackupCodeSet {
#[must_use]
pub fn generate(count: usize, length: usize) -> (Vec<String>, Self) {
let codes: Vec<String> = (0..count)
.map(|_| {
let mut bytes = vec![0u8; length];
rand::thread_rng().fill_bytes(&mut bytes);
bytes
.iter()
.fold(String::with_capacity(length * 2), |mut acc, b| {
use std::fmt::Write;
write!(acc, "{b:02x}").unwrap();
acc
})
})
.collect();
let stored = Self {
codes: codes.clone(),
};
(codes, stored)
}
pub fn validate(&mut self, code: &str) -> Result<(), TwoFactorError> {
let pos = self
.codes
.iter()
.position(|c| constant_time_eq(c.as_bytes(), code.as_bytes()));
match pos {
Some(idx) => {
self.codes.remove(idx);
Ok(())
}
None => Err(TwoFactorError::InvalidCode),
}
}
#[must_use]
pub fn remaining(&self) -> usize {
self.codes.len()
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut result: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
#[derive(derive_more::From, Debug)]
pub enum TwoFactorError {
InvalidCode,
TooManyAttempts,
NoBackupCodes,
}
impl core::fmt::Display for TwoFactorError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
TwoFactorError::InvalidCode => write!(f, "Invalid 2FA code"),
TwoFactorError::TooManyAttempts => {
write!(f, "Too many 2FA attempts — challenge locked")
}
TwoFactorError::NoBackupCodes => write!(f, "No backup codes remaining"),
}
}
}
impl std::error::Error for TwoFactorError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq(b"hello", b"hello"));
assert!(!constant_time_eq(b"hello", b"world"));
assert!(!constant_time_eq(b"short", b"longer"));
}
}