Skip to main content

totp_rfc/
totp.rs

1use hmac::{Hmac, KeyInit, Mac};
2use sha2::{Sha256, Sha512};
3use subtle::{Choice, ConditionallySelectable};
4
5use crate::hotp::{dynamic_truncate, generate_with_sha1};
6use crate::{Code, CodeError, Digits, Error, Secret};
7
8/// HMAC algorithms permitted by RFC 6238.
9#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
10#[non_exhaustive]
11pub enum Algorithm {
12    /// HMAC-SHA-1, the interoperable RFC default.
13    #[default]
14    Sha1,
15    /// HMAC-SHA-256.
16    Sha256,
17    /// HMAC-SHA-512.
18    Sha512,
19}
20
21impl Algorithm {
22    /// Returns the RFC-recommended key length for this algorithm, in bytes.
23    ///
24    /// Longer or shorter keys remain valid as long as they satisfy RFC
25    /// 4226's mandatory 128-bit minimum.
26    #[must_use]
27    pub const fn recommended_key_len(self) -> usize {
28        match self {
29            Self::Sha1 => 20,
30            Self::Sha256 => 32,
31            Self::Sha512 => 64,
32        }
33    }
34}
35
36/// A bounded TOTP validation window.
37///
38/// The window is expressed in time steps, not seconds. RFC 6238 recommends
39/// allowing at most one past step for ordinary network delay. Clock-drift
40/// resynchronization policy may deliberately use another bounded value.
41#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
42pub struct ValidationWindow {
43    past: u16,
44    future: u16,
45}
46
47impl ValidationWindow {
48    /// Validate only the current time step.
49    pub const CURRENT: Self = Self::new(0, 0);
50    /// RFC 6238's recommended maximum ordinary transmission-delay window.
51    pub const RFC_RECOMMENDED: Self = Self::new(1, 0);
52
53    /// Creates a window allowing the given number of past and future steps.
54    #[must_use]
55    pub const fn new(past: u16, future: u16) -> Self {
56        Self { past, future }
57    }
58
59    /// Returns the number of accepted past steps.
60    #[must_use]
61    pub const fn past(self) -> u16 {
62        self.past
63    }
64
65    /// Returns the number of accepted future steps.
66    #[must_use]
67    pub const fn future(self) -> u16 {
68        self.future
69    }
70}
71
72impl Default for ValidationWindow {
73    fn default() -> Self {
74        Self::RFC_RECOMMENDED
75    }
76}
77
78/// RFC 6238 TOTP system parameters.
79///
80/// Parameters are immutable so a prover and verifier can share an exact
81/// configuration without partial mutation.
82#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
83pub struct Totp {
84    algorithm: Algorithm,
85    digits: Digits,
86    period: u64,
87    epoch: u64,
88}
89
90struct MatchAccumulator {
91    found: Choice,
92    counter: u64,
93    drift: i32,
94}
95
96impl MatchAccumulator {
97    fn new() -> Self {
98        Self {
99            found: Choice::from(0),
100            counter: 0,
101            drift: 0,
102        }
103    }
104}
105
106impl Totp {
107    /// The RFC 6238 default period in seconds.
108    pub const DEFAULT_PERIOD: u64 = 30;
109
110    /// Creates and validates TOTP system parameters.
111    ///
112    /// `period` is `X` and `epoch` is `T0` in RFC 6238 terminology.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`Error::ZeroPeriod`] when `period` is zero.
117    pub const fn new(
118        algorithm: Algorithm,
119        digits: Digits,
120        period: u64,
121        epoch: u64,
122    ) -> Result<Self, Error> {
123        if period == 0 {
124            return Err(Error::ZeroPeriod);
125        }
126        Ok(Self {
127            algorithm,
128            digits,
129            period,
130            epoch,
131        })
132    }
133
134    /// Returns the selected HMAC algorithm.
135    #[must_use]
136    pub const fn algorithm(self) -> Algorithm {
137        self.algorithm
138    }
139
140    /// Returns the configured code width.
141    #[must_use]
142    pub const fn digits(self) -> Digits {
143        self.digits
144    }
145
146    /// Returns time-step size `X`, in seconds.
147    #[must_use]
148    pub const fn period(self) -> u64 {
149        self.period
150    }
151
152    /// Returns initial Unix time `T0`, in seconds.
153    #[must_use]
154    pub const fn epoch(self) -> u64 {
155        self.epoch
156    }
157
158    /// Calculates `T = floor((timestamp - T0) / X)`.
159    ///
160    /// The 64-bit result remains valid beyond the year 2038 as required by
161    /// RFC 6238.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`Error::TimestampBeforeEpoch`] when `timestamp` precedes
166    /// configured `T0`.
167    pub const fn counter_at(self, timestamp: u64) -> Result<u64, Error> {
168        if timestamp < self.epoch {
169            return Err(Error::TimestampBeforeEpoch {
170                timestamp,
171                epoch: self.epoch,
172            });
173        }
174        Ok((timestamp - self.epoch) / self.period)
175    }
176
177    /// Returns seconds remaining in the step containing `timestamp`.
178    ///
179    /// The result is in `1..=period`; a value equal to `period` means a new
180    /// time step has just begun.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`Error::TimestampBeforeEpoch`] when `timestamp` precedes
185    /// configured `T0`.
186    pub const fn seconds_remaining(self, timestamp: u64) -> Result<u64, Error> {
187        if timestamp < self.epoch {
188            return Err(Error::TimestampBeforeEpoch {
189                timestamp,
190                epoch: self.epoch,
191            });
192        }
193        Ok(self.period - ((timestamp - self.epoch) % self.period))
194    }
195
196    /// Generates a TOTP for a Unix timestamp in seconds.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`Error::TimestampBeforeEpoch`] when `timestamp` precedes
201    /// configured `T0`.
202    pub fn generate(&self, secret: &Secret<'_>, timestamp: u64) -> Result<Code, Error> {
203        let counter = self.counter_at(timestamp)?;
204        Ok(self.generate_for_counter(secret, counter))
205    }
206
207    /// Verifies a code only in the step containing `timestamp`.
208    ///
209    /// Comparison of well-formed codes is performed in constant time.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`VerifyError::Code`] for invalid code syntax or
214    /// [`VerifyError::Parameters`] when `timestamp` precedes configured `T0`.
215    pub fn verify(
216        &self,
217        secret: &Secret<'_>,
218        timestamp: u64,
219        candidate: &str,
220    ) -> Result<bool, VerifyError> {
221        let candidate = Code::parse(candidate, self.digits)?;
222        let counter = self.counter_at(timestamp)?;
223        Ok(self.generate_for_counter(secret, counter).ct_eq(candidate))
224    }
225
226    /// Searches a bounded window around the step containing `timestamp`.
227    ///
228    /// The current step is preferred, followed by past steps nearest-first
229    /// and future steps nearest-first. Every representable counter in the
230    /// configured window is evaluated even after a match, and the first match
231    /// is recorded with masked selection rather than position-dependent
232    /// control flow.
233    ///
234    /// RFC 6238 requires the caller to record successful use and reject a
235    /// replay of the same time-step code. The returned counter and drift are
236    /// suitable for that persistent state.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`VerifyError::Code`] for invalid code syntax or
241    /// [`VerifyError::Parameters`] when `timestamp` precedes configured `T0`.
242    pub fn verify_window(
243        &self,
244        secret: &Secret<'_>,
245        timestamp: u64,
246        window: ValidationWindow,
247        candidate: &str,
248    ) -> Result<Option<TotpMatch>, VerifyError> {
249        let candidate = Code::parse(candidate, self.digits)?;
250        let counter = self.counter_at(timestamp)?;
251        let mut matched = MatchAccumulator::new();
252
253        self.consider_match(secret, counter, 0, candidate, &mut matched);
254
255        for distance in 1..=window.past {
256            let Some(current) = counter.checked_sub(u64::from(distance)) else {
257                continue;
258            };
259            self.consider_match(
260                secret,
261                current,
262                -i32::from(distance),
263                candidate,
264                &mut matched,
265            );
266        }
267
268        for distance in 1..=window.future {
269            let Some(current) = counter.checked_add(u64::from(distance)) else {
270                continue;
271            };
272            self.consider_match(
273                secret,
274                current,
275                i32::from(distance),
276                candidate,
277                &mut matched,
278            );
279        }
280
281        Ok(if bool::from(matched.found) {
282            Some(TotpMatch {
283                counter: matched.counter,
284                drift: matched.drift,
285            })
286        } else {
287            None
288        })
289    }
290
291    fn consider_match(
292        &self,
293        secret: &Secret<'_>,
294        counter: u64,
295        drift: i32,
296        candidate: Code,
297        matched: &mut MatchAccumulator,
298    ) {
299        let equal = self
300            .generate_for_counter(secret, counter)
301            .ct_eq_choice(candidate);
302        let select = equal & !matched.found;
303        matched.counter = u64::conditional_select(&matched.counter, &counter, select);
304        matched.drift = i32::conditional_select(&matched.drift, &drift, select);
305        matched.found |= equal;
306    }
307
308    fn generate_for_counter(&self, secret: &Secret<'_>, counter: u64) -> Code {
309        let message = counter.to_be_bytes();
310        match self.algorithm {
311            Algorithm::Sha1 => generate_with_sha1(secret, counter, self.digits),
312            Algorithm::Sha256 => {
313                let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
314                    .expect("HMAC accepts keys of any length");
315                mac.update(&message);
316                dynamic_truncate(mac.finalize().into_bytes().as_slice(), self.digits)
317            }
318            Algorithm::Sha512 => {
319                let mut mac = Hmac::<Sha512>::new_from_slice(secret.as_bytes())
320                    .expect("HMAC accepts keys of any length");
321                mac.update(&message);
322                dynamic_truncate(mac.finalize().into_bytes().as_slice(), self.digits)
323            }
324        }
325    }
326}
327
328impl Default for Totp {
329    fn default() -> Self {
330        Self {
331            algorithm: Algorithm::Sha1,
332            digits: Digits::SIX,
333            period: Self::DEFAULT_PERIOD,
334            epoch: 0,
335        }
336    }
337}
338
339/// An error while verifying a TOTP.
340#[derive(Clone, Copy, Debug, Eq, PartialEq)]
341#[non_exhaustive]
342pub enum VerifyError {
343    /// The candidate code is not strict ASCII decimal syntax.
344    Code(CodeError),
345    /// The timestamp is invalid for the configured TOTP parameters.
346    Parameters(Error),
347}
348
349impl core::fmt::Display for VerifyError {
350    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
351        match self {
352            Self::Code(error) => write!(f, "invalid OTP code: {error}"),
353            Self::Parameters(error) => write!(f, "invalid TOTP input: {error}"),
354        }
355    }
356}
357
358#[cfg(feature = "std")]
359impl std::error::Error for VerifyError {
360    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
361        match self {
362            Self::Code(error) => Some(error),
363            Self::Parameters(error) => Some(error),
364        }
365    }
366}
367
368impl From<CodeError> for VerifyError {
369    fn from(value: CodeError) -> Self {
370        Self::Code(value)
371    }
372}
373
374impl From<Error> for VerifyError {
375    fn from(value: Error) -> Self {
376        Self::Parameters(value)
377    }
378}
379
380/// A successful TOTP validation-window match.
381#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
382pub struct TotpMatch {
383    counter: u64,
384    drift: i32,
385}
386
387impl TotpMatch {
388    /// Returns the matched 64-bit time-step counter.
389    #[must_use]
390    pub const fn counter(self) -> u64 {
391        self.counter
392    }
393
394    /// Returns prover drift in steps relative to the verifier timestamp.
395    ///
396    /// Negative values are in the past and positive values in the future.
397    #[must_use]
398    pub const fn drift(self) -> i32 {
399        self.drift
400    }
401}