Skip to main content

onetimepassword/
lib.rs

1//! # One-Time Password (OTP)
2//!
3//! Implementation of HMAC-Based One-Time Passwords (HOTP, [RFC 4226])
4//! and Time-Based One-Time Passwords (TOTP, [RFC 6238]).
5//!
6//! [RFC 4226]: https://tools.ietf.org/html/rfc4226
7//! [RFC 6238]: https://tools.ietf.org/html/rfc6238
8
9use core::{
10    fmt,
11    hint::{
12        assert_unchecked,
13        unreachable_unchecked,
14    },
15    num::NonZeroU64,
16};
17
18use hmac::{
19    Hmac,
20    KeyInit as _,
21    Mac as _,
22};
23use sha1::Sha1;
24use sha2::{
25    Sha256,
26    Sha512,
27};
28use subtle::ConstantTimeEq as _;
29
30/// Supported hashing algorithms for TOTP/HOTP.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Algorithm {
33    /// SHA-1 (typically used by legacy authenticators).
34    Sha1,
35    /// SHA-256 (recommended for new applications).
36    Sha256,
37    /// SHA-512 (provides highest security margin).
38    Sha512,
39}
40
41/// Potential errors that can occur during TOTP/HOTP generation or validation.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Error {
44    /// The time step cannot be zero.
45    ZeroStep,
46    /// Digits must be between 1 and 10.
47    InvalidDigits,
48    /// The secret key length is invalid for the chosen algorithm.
49    InvalidSecret,
50}
51
52impl fmt::Display for Error {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            | Error::ZeroStep => write!(f, "Time step cannot be zero"),
56            | Error::InvalidDigits => write!(f, "Digits must be between 1 and 10"),
57            | Error::InvalidSecret => write!(f, "Invalid secret key length"),
58        }
59    }
60}
61
62impl core::error::Error for Error {}
63
64/// Configuration for HMAC-based One-Time Password (HOTP).
65#[derive(Debug, Clone)]
66pub struct Hotp<'a> {
67    secret:    &'a [u8],
68    algorithm: Algorithm,
69    digits:    u8,
70}
71
72impl<'a> Hotp<'a> {
73    /// Creates a new HOTP configuration.
74    ///
75    /// # Arguments
76    /// * `secret` - The raw byte array of the shared secret.
77    /// * `algorithm` - The HMAC hashing algorithm.
78    /// * `digits` - Length of the output token (typically 6 or 8).
79    ///
80    /// # Errors
81    /// Returns `Error::InvalidDigits` if `digits` is 0 or greater than 10.
82    pub fn new<'s: 'a>(secret: &'s impl AsRef<[u8]>, algorithm: Algorithm, digits: u8) -> Result<Self, Error> {
83        let secret = secret.as_ref();
84
85        if digits == 0 || digits > 10 {
86            return Err(Error::InvalidDigits);
87        }
88
89        Ok(Self {
90            secret,
91            algorithm,
92            digits,
93        })
94    }
95
96    /// Returns the shared secret.
97    #[must_use]
98    pub fn secret(&self) -> &'a [u8] {
99        self.secret
100    }
101
102    /// Returns the hashing algorithm.
103    #[must_use]
104    pub fn algorithm(&self) -> Algorithm {
105        self.algorithm
106    }
107
108    /// Returns the number of digits in the output token.
109    #[must_use]
110    pub fn digits(&self) -> u8 {
111        self.digits
112    }
113
114    /// Implements the core HOTP generation algorithm (RFC 4226).
115    ///
116    /// # Errors
117    /// Returns `Error::InvalidSecret` if the internal HMAC implementation
118    /// rejects the secret length.
119    #[must_use]
120    pub fn generate(&self, counter: u64) -> Result<String, Error> {
121        // SAFETY: The constructor guarantees that `digits` is between 1 and 10.
122        unsafe {
123            assert_unchecked(self.digits > 0 && self.digits <= 10);
124        }
125
126        let counter_bytes = counter.to_be_bytes();
127        let mut p = [0u8; 4];
128
129        // RFC 4226 Section 5.3: Step 1 (Generate an HMAC value) and Step 2 (Generate a
130        // 4-byte string).
131        match self.algorithm {
132            | Algorithm::Sha1 => {
133                let mut mac = Hmac::<Sha1>::new_from_slice(self.secret).map_err(|_| Error::InvalidSecret)?;
134                mac.update(&counter_bytes);
135                let result = mac.finalize().into_bytes();
136
137                // RFC 4226 Section 5.4: Offset extraction (lower 4 bits of the last MAC byte).
138                let offset = usize::from(result[19] & 0x0F);
139
140                let Some(slice) = result.get(offset .. offset + 4) else {
141                    // SAFETY: `offset` is bounded to 0..=15 via bitwise AND.
142                    // The slice range is at most 15..19, which is strictly within the 20-byte SHA-1
143                    // output.
144                    unsafe { unreachable_unchecked() }
145                };
146                p.copy_from_slice(slice);
147            },
148            | Algorithm::Sha256 => {
149                let mut mac = Hmac::<Sha256>::new_from_slice(self.secret).map_err(|_| Error::InvalidSecret)?;
150                mac.update(&counter_bytes);
151                let result = mac.finalize().into_bytes();
152
153                // RFC 4226 Section 5.4: Offset extraction (lower 4 bits of the last MAC byte).
154                let offset = usize::from(result[31] & 0x0F);
155
156                let Some(slice) = result.get(offset .. offset + 4) else {
157                    // SAFETY: `offset` is bounded to 0..=15 via bitwise AND.
158                    // The slice range is at most 15..19, which is strictly within the 32-byte
159                    // SHA-256 output.
160                    unsafe { unreachable_unchecked() }
161                };
162                p.copy_from_slice(slice);
163            },
164            | Algorithm::Sha512 => {
165                let mut mac = Hmac::<Sha512>::new_from_slice(self.secret).map_err(|_| Error::InvalidSecret)?;
166                mac.update(&counter_bytes);
167                let result = mac.finalize().into_bytes();
168
169                // RFC 4226 Section 5.4: Offset extraction (lower 4 bits of the last MAC byte).
170                let offset = usize::from(result[63] & 0x0F);
171
172                let Some(slice) = result.get(offset .. offset + 4) else {
173                    // SAFETY: `offset` is bounded to 0..=15 via bitwise AND.
174                    // The slice range is at most 15..19, which is strictly within the 64-byte
175                    // SHA-512 output.
176                    unsafe { unreachable_unchecked() }
177                };
178                p.copy_from_slice(slice);
179            },
180        };
181
182        // RFC 4226 Section 5.3: Step 3 (Compute an HOTP value).
183        // RFC 4226 Section 5.4: Example of returning the dynamic binary code.
184        let binary_code =
185            (u32::from(p[0] & 0x7F)) << 24 | u32::from(p[1]) << 16 | u32::from(p[2]) << 8 | u32::from(p[3]);
186
187        let modulo = 10_u64.pow(u32::from(self.digits));
188        let final_code = u64::from(binary_code).rem_euclid(modulo);
189
190        Ok(format!("{:0width$}", final_code, width = self.digits as usize))
191    }
192
193    /// Verifies a user-provided token against a specific counter.
194    ///
195    /// # Errors
196    /// Returns `Error::InvalidSecret` if the internal HMAC implementation
197    /// rejects the secret length.
198    #[must_use]
199    pub fn verify(&self, code: &str, counter: u64) -> Result<bool, Error> {
200        // SAFETY: The constructor guarantees that `digits` is between 1 and 10.
201        unsafe {
202            assert_unchecked(self.digits > 0 && self.digits <= 10);
203        }
204
205        if code.len() != self.digits as usize {
206            return Ok(false);
207        }
208
209        let expected_code = self.generate(counter)?;
210        Ok(bool::from(expected_code.as_bytes().ct_eq(code.as_bytes())))
211    }
212}
213
214/// Configuration for Time-based One-Time Password (TOTP).
215#[derive(Debug, Clone)]
216pub struct Totp<'a> {
217    hotp:         Hotp<'a>,
218    step_seconds: u64,
219    t0:           u64,
220}
221
222impl<'a> Totp<'a> {
223    /// Creates a new TOTP configuration.
224    ///
225    /// # Arguments
226    /// * `secret` - The raw byte array of the shared secret.
227    /// * `algorithm` - The HMAC hashing algorithm.
228    /// * `digits` - Length of the output token (typically 6 or 8).
229    /// * `step_seconds` - The time step window (typically 30).
230    /// * `t0` - The Unix epoch start (typically 0).
231    ///
232    /// # Errors
233    /// Returns `Error::InvalidDigits` if `digits` is 0 or greater than 10.
234    #[must_use]
235    pub fn new<'s: 'a>(
236        secret: &'s impl AsRef<[u8]>,
237        algorithm: Algorithm,
238        digits: u8,
239        step_seconds: NonZeroU64,
240        t0: u64,
241    ) -> Result<Self, Error> {
242        Ok(Self {
243            hotp: Hotp::new(secret, algorithm, digits)?,
244            step_seconds: step_seconds.get(),
245            t0,
246        })
247    }
248
249    /// Returns the shared secret.
250    #[must_use]
251    pub fn secret(&self) -> &'a [u8] {
252        self.hotp.secret()
253    }
254
255    /// Returns the hashing algorithm.
256    #[must_use]
257    pub fn algorithm(&self) -> Algorithm {
258        self.hotp.algorithm()
259    }
260
261    /// Returns the number of digits in the output token.
262    #[must_use]
263    pub fn digits(&self) -> u8 {
264        self.hotp.digits()
265    }
266
267    /// Returns the time step window in seconds.
268    #[must_use]
269    pub fn step_seconds(&self) -> u64 {
270        self.step_seconds
271    }
272
273    /// Returns the Unix epoch start.
274    #[must_use]
275    pub fn t0(&self) -> u64 {
276        self.t0
277    }
278
279    /// Generates a TOTP code for a specific Unix timestamp.
280    ///
281    /// # Errors
282    /// Returns `Error::InvalidSecret` if the internal HMAC implementation
283    /// rejects the secret length.
284    #[must_use]
285    pub fn generate(&self, unix_time_sec: u64) -> Result<String, Error> {
286        let step = self.calculate_step(unix_time_sec);
287        self.hotp.generate(step)
288    }
289
290    /// Verifies a user-provided token against a specific timestamp, allowing
291    /// for clock skew.
292    ///
293    /// # Arguments
294    /// * `code` - The user-provided token string.
295    /// * `unix_time_sec` - The current epoch time in seconds.
296    /// * `skew_tolerance` - The number of time steps before and after the
297    ///   current step to check.
298    ///
299    /// # Errors
300    /// Returns `Error::InvalidSecret` if the internal HMAC implementation
301    /// rejects the secret length.
302    #[must_use]
303    pub fn verify(&self, code: &str, unix_time_sec: u64, skew_tolerance: u64) -> Result<bool, Error> {
304        // SAFETY: The constructor guarantees that `digits` is between 1 and 10.
305        unsafe {
306            assert_unchecked(self.hotp.digits > 0 && self.hotp.digits <= 10);
307        }
308
309        if code.len() != self.hotp.digits as usize {
310            return Ok(false);
311        }
312
313        let current_step = self.calculate_step(unix_time_sec);
314        let mut is_valid = false;
315
316        let start_step = current_step.saturating_sub(skew_tolerance);
317        let end_step = current_step.saturating_add(skew_tolerance);
318
319        for step in start_step ..= end_step {
320            let expected_code = self.hotp.generate(step)?;
321            let eq_result = expected_code.as_bytes().ct_eq(code.as_bytes());
322            is_valid |= bool::from(eq_result);
323        }
324
325        Ok(is_valid)
326    }
327
328    /// Converts a continuous Unix timestamp into a discrete time step counter.
329    #[must_use]
330    fn calculate_step(&self, unix_time: u64) -> u64 {
331        // SAFETY: `step_seconds` is sourced from a `NonZeroU64` in the constructor,
332        // preventing division by zero.
333        unsafe {
334            assert_unchecked(self.step_seconds > 0);
335        }
336
337        // RFC 6238 Section 4.2: T = (Current Unix time - T0) / X
338        if unix_time < self.t0 {
339            0
340        } else {
341            unix_time.saturating_sub(self.t0) / self.step_seconds
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use core::num::NonZeroU64;
349
350    use super::*;
351
352    #[test]
353    fn rfc4226_hotp_tests() -> Result<(), Error> {
354        let secret = b"12345678901234567890";
355        let hotp = Hotp::new(secret, Algorithm::Sha1, 6)?;
356
357        assert_eq!(hotp.secret(), secret);
358        assert_eq!(hotp.algorithm(), Algorithm::Sha1);
359        assert_eq!(hotp.digits(), 6);
360
361        let expected_results = [
362            (0, "755224"),
363            (1, "287082"),
364            (2, "359152"),
365            (3, "969429"),
366            (4, "338314"),
367            (5, "254676"),
368            (6, "287922"),
369            (7, "162583"),
370            (8, "399871"),
371            (9, "520489"),
372        ];
373
374        for (count, expected) in expected_results {
375            assert_eq!(hotp.generate(count)?, expected, "HOTP mismatch at count {}", count);
376            assert!(hotp.verify(expected, count)?, "Verification failed at count {}", count);
377        }
378
379        Ok(())
380    }
381
382    #[test]
383    fn rfc6238_totp_tests_sha1() -> Result<(), Error> {
384        let secret: &[u8; 20] = b"12345678901234567890";
385        let step = NonZeroU64::new(30).ok_or(Error::ZeroStep)?;
386        let totp = Totp::new(secret, Algorithm::Sha1, 8, step, 0)?;
387
388        assert_eq!(totp.secret(), secret);
389        assert_eq!(totp.algorithm(), Algorithm::Sha1);
390        assert_eq!(totp.digits(), 8);
391        assert_eq!(totp.step_seconds(), 30);
392        assert_eq!(totp.t0(), 0);
393
394        let expected_results = [
395            (59, "94287082"),
396            (1111111109, "07081804"),
397            (1111111111, "14050471"),
398            (1234567890, "89005924"),
399            (2000000000, "69279037"),
400            (20000000000, "65353130"),
401        ];
402
403        for (time, expected) in expected_results {
404            assert_eq!(
405                totp.generate(time)?,
406                expected,
407                "TOTP SHA1 generation failed at time {}",
408                time
409            );
410            assert!(
411                totp.verify(expected, time, 0)?,
412                "TOTP SHA1 verification failed at time {}",
413                time
414            );
415        }
416
417        Ok(())
418    }
419
420    #[test]
421    fn rfc6238_totp_tests_sha256() -> Result<(), Error> {
422        let secret: &[u8; 32] = b"12345678901234567890123456789012";
423        let step = NonZeroU64::new(30).ok_or(Error::ZeroStep)?;
424        let totp = Totp::new(secret, Algorithm::Sha256, 8, step, 0)?;
425
426        let expected_results = [
427            (59, "46119246"),
428            (1111111109, "68084774"),
429            (1111111111, "67062674"),
430            (1234567890, "91819424"),
431            (2000000000, "90698825"),
432            (20000000000, "77737706"),
433        ];
434
435        for (time, expected) in expected_results {
436            assert_eq!(
437                totp.generate(time)?,
438                expected,
439                "TOTP SHA256 generation failed at time {}",
440                time
441            );
442            assert!(
443                totp.verify(expected, time, 0)?,
444                "TOTP SHA256 verification failed at time {}",
445                time
446            );
447        }
448
449        Ok(())
450    }
451
452    #[test]
453    fn rfc6238_totp_tests_sha512() -> Result<(), Error> {
454        let secret: &[u8; 64] = b"1234567890123456789012345678901234567890123456789012345678901234";
455        let step = NonZeroU64::new(30).ok_or(Error::ZeroStep)?;
456        let totp = Totp::new(secret, Algorithm::Sha512, 8, step, 0)?;
457
458        let expected_results = [
459            (59, "90693936"),
460            (1111111109, "25091201"),
461            (1111111111, "99943326"),
462            (1234567890, "93441116"),
463            (2000000000, "38618901"),
464            (20000000000, "47863826"),
465        ];
466
467        for (time, expected) in expected_results {
468            assert_eq!(
469                totp.generate(time)?,
470                expected,
471                "TOTP SHA512 generation failed at time {}",
472                time
473            );
474            assert!(
475                totp.verify(expected, time, 0)?,
476                "TOTP SHA512 verification failed at time {}",
477                time
478            );
479        }
480
481        Ok(())
482    }
483}