Skip to main content

_synta/
otp.rs

1//! Python bindings for HOTP (RFC 4226) and TOTP (RFC 6238) OTP generators.
2
3use pyo3::exceptions::PyValueError;
4use pyo3::prelude::*;
5use synta_certificate::{constant_time_eq, default_hmac_provider, HmacProvider};
6
7// ── Shared computation ────────────────────────────────────────────────────────
8
9/// HOTP computation (RFC 4226 §5.3).
10///
11/// `counter` is an 8-byte big-endian integer.  `digits` controls the
12/// output width (typically 6 or 8).  `algorithm` is one of `"sha1"`,
13/// `"sha256"`, `"sha384"`, or `"sha512"`.
14fn hotp_compute(key: &[u8], counter: u64, digits: u32, algorithm: &str) -> PyResult<String> {
15    let hs = default_hmac_provider()
16        .hmac_compute(algorithm, key, &counter.to_be_bytes())
17        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
18
19    // Dynamic truncation (RFC 4226 §5.3).
20    let offset = (hs[hs.len() - 1] & 0x0f) as usize;
21    let p = u32::from_be_bytes([hs[offset], hs[offset + 1], hs[offset + 2], hs[offset + 3]])
22        & 0x7fff_ffff;
23    let modulus = 10u32.checked_pow(digits).unwrap_or(u32::MAX);
24    let otp = p % modulus;
25    Ok(format!("{:0>width$}", otp, width = digits as usize))
26}
27
28fn validate_algorithm(algorithm: &str) -> PyResult<()> {
29    match algorithm {
30        "sha1" | "sha256" | "sha384" | "sha512" => Ok(()),
31        other => Err(PyValueError::new_err(format!(
32            "unsupported OTP algorithm: {other}; expected sha1, sha256, sha384, or sha512"
33        ))),
34    }
35}
36
37// ── HOTP ──────────────────────────────────────────────────────────────────────
38
39/// HMAC-based One-Time Password generator (RFC 4226).
40///
41/// Generates and verifies counter-based OTPs using HMAC-SHA1 (default),
42/// HMAC-SHA256, HMAC-SHA384, or HMAC-SHA512.
43///
44/// ```python,ignore
45/// import synta.crypto
46///
47/// # RFC 4226 test vector — key is the ASCII string "12345678901234567890"
48/// key = bytes.fromhex("3132333435363738393031323334353637383930")
49/// hotp = synta.crypto.HOTP(key, digits=6, algorithm="sha1")
50/// print(hotp.generate(0))    # "755224"
51/// print(hotp.generate(1))    # "287082"
52/// # Verify with look-ahead window of 5:
53/// next_counter = hotp.verify("287082", counter=0, look_ahead=5)
54/// assert next_counter == 1
55/// ```
56#[pyclass(frozen, name = "HOTP")]
57pub struct PyHOTP {
58    key: Vec<u8>,
59    digits: u32,
60    algorithm: String,
61}
62
63#[pymethods]
64impl PyHOTP {
65    /// Create a new HOTP instance.
66    ///
67    /// ``key`` is the shared secret (raw bytes).  ``digits`` is the OTP
68    /// length (default 6).  ``algorithm`` is the HMAC hash (default
69    /// ``"sha1"``); one of ``"sha1"``, ``"sha256"``, ``"sha384"``,
70    /// ``"sha512"``.
71    #[new]
72    #[pyo3(signature = (key, digits = 6, algorithm = "sha1"))]
73    fn new(key: &[u8], digits: u32, algorithm: &str) -> PyResult<Self> {
74        validate_algorithm(algorithm)?;
75        if !(1..=9).contains(&digits) {
76            return Err(PyValueError::new_err(
77                "digits must be between 1 and 9 inclusive",
78            ));
79        }
80        Ok(Self {
81            key: key.to_vec(),
82            digits,
83            algorithm: algorithm.to_string(),
84        })
85    }
86
87    /// Generate the OTP for the given ``counter`` value.
88    ///
89    /// Returns a zero-padded decimal string of length ``digits``.
90    fn generate(&self, counter: u64) -> PyResult<String> {
91        hotp_compute(&self.key, counter, self.digits, &self.algorithm)
92    }
93
94    /// Verify ``code`` against a window of counters starting at ``counter``.
95    ///
96    /// Checks ``counter`` through ``counter + look_ahead`` (inclusive) using
97    /// constant-time comparison.  Returns the matching counter value on
98    /// success, or ``None`` if no counter in the window matches.
99    #[pyo3(signature = (code, counter, look_ahead = 0))]
100    fn verify(&self, code: &str, counter: u64, look_ahead: u64) -> PyResult<Option<u64>> {
101        for i in 0..=look_ahead {
102            let expected = hotp_compute(&self.key, counter + i, self.digits, &self.algorithm)?;
103            if constant_time_eq(code.as_bytes(), expected.as_bytes()) {
104                return Ok(Some(counter + i));
105            }
106        }
107        Ok(None)
108    }
109
110    fn __repr__(&self) -> String {
111        format!(
112            "HOTP(digits={}, algorithm={:?})",
113            self.digits, self.algorithm
114        )
115    }
116}
117
118// ── TOTP ──────────────────────────────────────────────────────────────────────
119
120/// Time-based One-Time Password generator (RFC 6238).
121///
122/// The counter is derived from the Unix timestamp divided by the time step
123/// (``period`` seconds, default 30).  Supports HMAC-SHA1, HMAC-SHA256,
124/// HMAC-SHA384, and HMAC-SHA512.
125///
126/// ```python,ignore
127/// import synta.crypto, time
128///
129/// key = bytes.fromhex("3132333435363738393031323334353637383930")
130/// totp = synta.crypto.TOTP(key, digits=8, algorithm="sha1", period=30)
131/// otp = totp.now()                        # current time step
132/// otp = totp.generate(int(time.time()))   # explicit Unix timestamp
133/// ```
134#[pyclass(frozen, name = "TOTP")]
135pub struct PyTOTP {
136    key: Vec<u8>,
137    digits: u32,
138    algorithm: String,
139    period: u64,
140}
141
142#[pymethods]
143impl PyTOTP {
144    /// Create a new TOTP instance.
145    ///
146    /// ``key`` is the shared secret (raw bytes).  ``digits`` is the OTP
147    /// length (default 6).  ``algorithm`` is the HMAC hash (default
148    /// ``"sha1"``); one of ``"sha1"``, ``"sha256"``, ``"sha384"``,
149    /// ``"sha512"``.  ``period`` is the time step in seconds (default 30).
150    #[new]
151    #[pyo3(signature = (key, digits = 6, algorithm = "sha1", period = 30))]
152    fn new(key: &[u8], digits: u32, algorithm: &str, period: u64) -> PyResult<Self> {
153        validate_algorithm(algorithm)?;
154        if !(1..=9).contains(&digits) {
155            return Err(PyValueError::new_err(
156                "digits must be between 1 and 9 inclusive",
157            ));
158        }
159        if period == 0 {
160            return Err(PyValueError::new_err("TOTP period must be positive"));
161        }
162        Ok(Self {
163            key: key.to_vec(),
164            digits,
165            algorithm: algorithm.to_string(),
166            period,
167        })
168    }
169
170    /// Generate the OTP for the given Unix ``timestamp`` (seconds).
171    fn generate(&self, timestamp: u64) -> PyResult<String> {
172        let counter = timestamp / self.period;
173        hotp_compute(&self.key, counter, self.digits, &self.algorithm)
174    }
175
176    /// Generate the OTP for the current system time.
177    fn now(&self) -> PyResult<String> {
178        let ts = std::time::SystemTime::now()
179            .duration_since(std::time::UNIX_EPOCH)
180            .map_err(|_| PyValueError::new_err("system clock error"))?
181            .as_secs();
182        self.generate(ts)
183    }
184
185    fn __repr__(&self) -> String {
186        format!(
187            "TOTP(digits={}, algorithm={:?}, period={})",
188            self.digits, self.algorithm, self.period
189        )
190    }
191}