_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}