use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct Code {
pub value: String,
pub remaining_secs: u64,
pub step_secs: u64,
}
fn from_value(value: &str) -> Result<TOTP> {
let trimmed = value.trim();
if trimmed.starts_with("otpauth://") {
TOTP::from_url(trimmed).map_err(|e| Error::InvalidTotp(e.to_string()))
} else {
let bytes = TotpSecret::Encoded(trimmed.to_owned())
.to_bytes()
.map_err(|e| Error::InvalidTotp(format!("base32 secret: {e}")))?;
TOTP::new(Algorithm::SHA1, 6, 1, 30, bytes, None, "ks".to_owned())
.map_err(|e| Error::InvalidTotp(e.to_string()))
}
}
pub fn current(value: &str) -> Result<Code> {
let totp = from_value(value)?;
let code = totp
.generate_current()
.map_err(|e| Error::InvalidTotp(e.to_string()))?;
let remaining = totp.ttl().map_err(|e| Error::InvalidTotp(e.to_string()))?;
Ok(Code {
value: code,
remaining_secs: remaining,
step_secs: totp.step,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_from_base32_secret() {
let code = current("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP").expect("generate");
assert_eq!(code.value.len(), 6);
assert!(code.value.chars().all(|c| c.is_ascii_digit()));
assert_eq!(code.step_secs, 30);
assert!(code.remaining_secs <= 30);
}
#[test]
fn rejects_garbage() {
assert!(current("not a secret!@#").is_err());
}
}