use thiserror::Error;
use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
const ALGORITHM: Algorithm = Algorithm::SHA1;
const DIGITS: usize = 6;
const STEP_SECONDS: u64 = 30;
const DEFAULT_SKEW: u8 = 1;
#[derive(Debug, Error)]
pub enum TotpError {
#[error("invalid base32 secret")]
InvalidSecret,
#[error("totp setup failed: {0}")]
Setup(String),
}
#[derive(Debug, Clone)]
pub struct Secret {
raw: Vec<u8>,
}
impl Secret {
pub fn generate() -> Self {
let secret = TotpSecret::generate_secret();
Self {
raw: secret.to_bytes().expect("generated secret has bytes form"),
}
}
pub fn from_base32(s: &str) -> Option<Self> {
let bytes = TotpSecret::Encoded(s.to_string()).to_bytes().ok()?;
Some(Self { raw: bytes })
}
pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
Self { raw: bytes.into() }
}
pub fn as_base32(&self) -> String {
TotpSecret::Raw(self.raw.clone()).to_encoded().to_string()
}
pub fn as_bytes(&self) -> &[u8] {
&self.raw
}
}
pub fn provisioning_uri(secret: &Secret, issuer: &str, account: &str) -> String {
match TOTP::new(
ALGORITHM,
DIGITS,
DEFAULT_SKEW,
STEP_SECONDS,
secret.raw.clone(),
Some(issuer.to_string()),
account.to_string(),
) {
Ok(totp) => totp.get_url(),
Err(_) => String::new(),
}
}
pub fn verify(secret: &Secret, code: &str) -> bool {
verify_with_skew(secret, code, DEFAULT_SKEW)
}
pub fn verify_with_skew(secret: &Secret, code: &str, skew: u8) -> bool {
let Ok(totp) = TOTP::new(
ALGORITHM,
DIGITS,
skew,
STEP_SECONDS,
secret.raw.clone(),
None,
String::new(),
) else {
return false;
};
totp.check_current(code).unwrap_or(false)
}
pub fn generate_current(secret: &Secret) -> Option<String> {
let totp = TOTP::new(
ALGORITHM,
DIGITS,
DEFAULT_SKEW,
STEP_SECONDS,
secret.raw.clone(),
None,
String::new(),
)
.ok()?;
totp.generate_current().ok()
}
#[cfg(test)]
mod tests {
use super::*;
const EXPECTED_SECRET_BYTES: usize = 20;
#[test]
fn generated_secret_has_expected_length() {
let secret = Secret::generate();
assert_eq!(secret.as_bytes().len(), EXPECTED_SECRET_BYTES);
}
#[test]
fn base32_round_trip_preserves_secret_bytes() {
let original = Secret::generate();
let encoded = original.as_base32();
let decoded = Secret::from_base32(&encoded).expect("valid base32");
assert_eq!(original.raw, decoded.raw);
}
#[test]
fn from_base32_rejects_invalid_input() {
assert!(Secret::from_base32("not valid base32 !!").is_none());
}
#[test]
fn provisioning_uri_includes_issuer_and_account() {
let secret = Secret::from_bytes([1u8; 20]);
let uri = provisioning_uri(&secret, "Sidevers", "alice@example.com");
assert!(uri.starts_with("otpauth://totp/"), "uri: {uri}");
assert!(uri.contains("Sidevers"), "uri: {uri}");
assert!(uri.contains("alice%40example.com") || uri.contains("alice@example.com"));
}
#[test]
fn verify_accepts_generated_current_code() {
let secret = Secret::generate();
let code = generate_current(&secret).expect("can generate");
assert!(verify(&secret, &code));
}
#[test]
fn verify_rejects_wrong_code() {
let secret = Secret::generate();
assert!(!verify(&secret, "000000"));
}
#[test]
fn verify_rejects_malformed_code() {
let secret = Secret::generate();
assert!(!verify(&secret, "abcdef"));
assert!(!verify(&secret, ""));
assert!(!verify(&secret, "12345"));
assert!(!verify(&secret, "1234567"));
}
}