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,
}
fn from_value(value: &str) -> Result<TOTP> {
let trimmed = value.trim();
if trimmed.starts_with("otpauth://") {
TOTP::from_url_unchecked(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}")))?;
Ok(TOTP::new_unchecked(
Algorithm::SHA1,
6,
1,
30,
bytes,
None,
"ks".to_owned(),
))
}
}
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,
})
}
#[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!(code.remaining_secs <= 30);
}
#[test]
fn accepts_real_world_short_secret() {
let code = current("JBSWY3DPEHPK3PXP").expect("80-bit secret must work");
assert_eq!(code.value.len(), 6);
}
#[test]
fn accepts_short_secret_via_otpauth_url() {
let code = current("otpauth://totp/Demo:alice?secret=JBSWY3DPEHPK3PXP&issuer=Demo")
.expect("80-bit url secret must work");
assert_eq!(code.value.len(), 6);
}
#[test]
fn rejects_garbage() {
assert!(current("not a secret!@#").is_err());
}
}