use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use synta_certificate::{constant_time_eq, default_hmac_provider, HmacProvider};
fn hotp_compute(key: &[u8], counter: u64, digits: u32, algorithm: &str) -> PyResult<String> {
let hs = default_hmac_provider()
.hmac_compute(algorithm, key, &counter.to_be_bytes())
.map_err(|e| PyValueError::new_err(format!("{e}")))?;
let offset = (hs[hs.len() - 1] & 0x0f) as usize;
let p = u32::from_be_bytes([hs[offset], hs[offset + 1], hs[offset + 2], hs[offset + 3]])
& 0x7fff_ffff;
let modulus = 10u32.checked_pow(digits).unwrap_or(u32::MAX);
let otp = p % modulus;
Ok(format!("{:0>width$}", otp, width = digits as usize))
}
fn validate_algorithm(algorithm: &str) -> PyResult<()> {
match algorithm {
"sha1" | "sha256" | "sha384" | "sha512" => Ok(()),
other => Err(PyValueError::new_err(format!(
"unsupported OTP algorithm: {other}; expected sha1, sha256, sha384, or sha512"
))),
}
}
#[pyclass(frozen, name = "HOTP")]
pub struct PyHOTP {
key: Vec<u8>,
digits: u32,
algorithm: String,
}
#[pymethods]
impl PyHOTP {
#[new]
#[pyo3(signature = (key, digits = 6, algorithm = "sha1"))]
fn new(key: &[u8], digits: u32, algorithm: &str) -> PyResult<Self> {
validate_algorithm(algorithm)?;
if !(1..=9).contains(&digits) {
return Err(PyValueError::new_err(
"digits must be between 1 and 9 inclusive",
));
}
Ok(Self {
key: key.to_vec(),
digits,
algorithm: algorithm.to_string(),
})
}
fn generate(&self, counter: u64) -> PyResult<String> {
hotp_compute(&self.key, counter, self.digits, &self.algorithm)
}
#[pyo3(signature = (code, counter, look_ahead = 0))]
fn verify(&self, code: &str, counter: u64, look_ahead: u64) -> PyResult<Option<u64>> {
for i in 0..=look_ahead {
let expected = hotp_compute(&self.key, counter + i, self.digits, &self.algorithm)?;
if constant_time_eq(code.as_bytes(), expected.as_bytes()) {
return Ok(Some(counter + i));
}
}
Ok(None)
}
fn __repr__(&self) -> String {
format!(
"HOTP(digits={}, algorithm={:?})",
self.digits, self.algorithm
)
}
}
#[pyclass(frozen, name = "TOTP")]
pub struct PyTOTP {
key: Vec<u8>,
digits: u32,
algorithm: String,
period: u64,
}
#[pymethods]
impl PyTOTP {
#[new]
#[pyo3(signature = (key, digits = 6, algorithm = "sha1", period = 30))]
fn new(key: &[u8], digits: u32, algorithm: &str, period: u64) -> PyResult<Self> {
validate_algorithm(algorithm)?;
if !(1..=9).contains(&digits) {
return Err(PyValueError::new_err(
"digits must be between 1 and 9 inclusive",
));
}
if period == 0 {
return Err(PyValueError::new_err("TOTP period must be positive"));
}
Ok(Self {
key: key.to_vec(),
digits,
algorithm: algorithm.to_string(),
period,
})
}
fn generate(&self, timestamp: u64) -> PyResult<String> {
let counter = timestamp / self.period;
hotp_compute(&self.key, counter, self.digits, &self.algorithm)
}
fn now(&self) -> PyResult<String> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| PyValueError::new_err("system clock error"))?
.as_secs();
self.generate(ts)
}
fn __repr__(&self) -> String {
format!(
"TOTP(digits={}, algorithm={:?}, period={})",
self.digits, self.algorithm, self.period
)
}
}