Skip to main content

otp/
totp.rs

1use crate::{Algorithm, Secret, encoding, hotp::Hotp};
2
3pub struct Totp {
4    period: u64,
5    hotp: Hotp,
6}
7
8impl Default for Totp {
9    fn default() -> Self {
10        Self {
11            hotp: Hotp::default(),
12            period: 30,
13        }
14    }
15}
16
17impl Totp {
18    /// Creates a new [`Totp`] instance with the specified configuration.
19    ///
20    /// Internally, this wraps an [`Hotp`] instance and uses time-based counter
21    /// calculations according to the specified period.
22    ///
23    /// # Arguments
24    ///
25    /// * `alg` - The hashing algorithm to use (e.g., [`Algorithm::SHA1`], [`Algorithm::SHA256`], or [`Algorithm::SHA512`]).
26    /// * `issuer` - The name of the service or provider (e.g., `"GitHub"` or `"example.com"`).
27    /// * `label` - An identifier for the user account (e.g., `"alice@example.com"`).
28    /// * `digits` - Number of digits in the generated OTP (typically 6 or 8).
29    /// * `period` - Time step duration in seconds (usually 30).
30    /// * `secret` - The shared secret key used to generate the HMAC.
31    ///
32    /// # Returns
33    ///
34    /// Returns a new instance of [`Totp`] configured with the provided parameters.
35    ///
36    /// # Example
37    ///
38    /// ```rust
39    /// use otp::{Totp, Algorithm, Secret};
40    ///
41    /// let totp = Totp::new(
42    ///     Algorithm::SHA1,
43    ///     "example".into(),
44    ///     "alice@example.com".into(),
45    ///     6,
46    ///     30,
47    ///     Secret::from_bytes(b"supersecret"),
48    /// );
49    /// ```
50    pub fn new(
51        alg: Algorithm,
52        issuer: String,
53        label: String,
54        digits: u8,
55        period: u64,
56        secret: Secret,
57    ) -> Self {
58        Self {
59            period,
60            hotp: Hotp::new(alg, issuer, label, digits, Default::default(), secret),
61        }
62    }
63
64    /// Generates a TOTP code for the current system time using the configured algorithm and secret.
65    ///
66    /// Internally, this method computes the number of time steps (counters) since the Unix epoch,
67    /// and uses that to derive the OTP value.
68    ///
69    /// # Returns
70    /// A numeric TOTP code as a `u32`.
71    ///
72    /// # Panics
73    /// Panics if system time is before the Unix epoch.
74    ///
75    /// # Example
76    /// ```rust
77    /// let totp = otp::Totp::default();
78    /// let otp = totp.generate();
79    /// println!("OTP: {}", otp);
80    /// ```
81    ///
82    /// # References
83    /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-4)
84    pub fn generate(&self) -> u32 {
85        let now = std::time::SystemTime::now()
86            .duration_since(std::time::UNIX_EPOCH)
87            .expect("Clock may have gone backwards")
88            .as_secs();
89
90        self.generate_at(now)
91    }
92
93    /// Generates a TOTP code for a specific timestamp (in seconds since Unix epoch).
94    ///
95    /// This method is useful when simulating or verifying TOTP behavior
96    /// for a given point in time.
97    ///
98    /// # Arguments
99    /// * `timestamp_secs` - The Unix timestamp in seconds
100    ///
101    /// # Returns
102    /// A numeric TOTP code as a `u32`.
103    ///
104    /// # Example
105    /// ```rust
106    /// let totp = otp::Totp::default();
107    /// let otp = totp.generate_at(1_600_000_000); // fixed timestamp
108    /// ```
109    ///
110    /// # References
111    /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-4)
112    pub fn generate_at(&self, timestamp_secs: u64) -> u32 {
113        let counter = timestamp_secs / self.period;
114        self.hotp.generate_at(counter)
115    }
116
117    /// Verifies whether a given OTP is valid for a timestamp, within a configurable window.
118    ///
119    /// This method accounts for small clock skews by checking OTP values generated
120    /// before and after the given timestamp by a number of time steps defined by `window`.
121    ///
122    /// # Arguments
123    /// * `otp`            - The OTP value to check
124    /// * `timestamp_secs` - The Unix timestamp (in seconds) to check against
125    /// * `window`         - The allowed time-step drift (in units of `period`)
126    ///
127    /// # Returns
128    /// `true` if the OTP is valid within the given window; otherwise, `false`.
129    ///
130    /// # Example
131    /// ```rust
132    /// let totp = otp::Totp::default();
133    /// let timestamp = 1_600_000_000;
134    /// let otp = totp.generate_at(timestamp);
135    /// assert!(totp.verify(otp, timestamp + 20, 1)); // within window
136    /// ```
137    ///
138    /// # References
139    /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2)
140    pub fn verify(&self, otp: u32, timestamp_secs: u64, window: u64) -> bool {
141        let counter = timestamp_secs / self.period;
142        self.hotp.verify(otp, counter, window)
143    }
144
145    /// Generates a Key URI string in the format compatible with Google Authenticator and other TOTP/HOTP apps.
146    ///
147    /// This URI can be encoded as a QR code and scanned by authenticator apps (e.g., Google Authenticator, Authy)
148    /// to configure the OTP settings automatically.
149    ///
150    /// The URI format follows the [Key URI Format] specification:
151    ///
152    /// ```text
153    /// otpauth://TYPE/LABEL?PARAMETERS
154    /// ```
155    ///
156    /// For example, a TOTP URI might look like:
157    ///
158    /// ```text
159    /// otpauth://totp/Example%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
160    /// ```
161    ///
162    /// # Format Details
163    /// - `TYPE`: Either `totp` or `hotp`
164    /// - `LABEL`: Usually `issuer:account`, URL-encoded
165    /// - `secret`: Base32-encoded secret key
166    /// - `issuer`: The provider or service name (optional, but recommended)
167    /// - `algorithm`: Hash function used (e.g., SHA1, SHA256, SHA512)
168    /// - `digits`: Number of digits in the OTP (typically 6 or 8)
169    /// - `period` (TOTP only): Time step in seconds (e.g., 30)
170    /// - `counter` (HOTP only): Current counter value
171    ///
172    /// # Returns
173    /// A `String` containing the `otpauth://` URI.
174    ///
175    /// # Example
176    /// ```rust
177    /// use otp::{Totp, Algorithm, Secret};
178    ///
179    /// let totp = Totp::new(
180    ///     Algorithm::SHA256,
181    ///     "Example".into(),
182    ///     "alice@example.com".into(),
183    ///     6,
184    ///     30,
185    ///     Secret::from_bytes(b"supersecretkey")
186    /// );
187    ///
188    /// let uri = totp.to_uri();
189    /// assert!(uri.starts_with("otpauth://totp/"));
190    /// ```
191    ///
192    /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
193    pub fn to_uri(&self) -> String {
194        let secret = self.secret().into_base32();
195        let label = if self.issuer().is_empty() {
196            encoding::url::encode(self.label().as_bytes())
197        } else {
198            encoding::url::encode(format!("{}:{}", &self.issuer(), &self.label()).as_bytes())
199        };
200        let issuer = if !self.issuer().is_empty() {
201            format!(
202                "&issuer={}",
203                encoding::url::encode(self.issuer().as_bytes())
204            )
205        } else {
206            String::new()
207        };
208        let digits = self.digits();
209        let period = self.period;
210        let alg = self.alg().to_string();
211
212        format!(
213            "otpauth://totp/{label}?secret={secret}{issuer}&algorithm={alg}&digits={digits}&period={period}"
214        )
215    }
216
217    #[inline]
218    pub fn alg(&self) -> Algorithm {
219        self.hotp.alg()
220    }
221
222    #[inline]
223    pub fn issuer(&self) -> &str {
224        self.hotp.issuer()
225    }
226
227    #[inline]
228    pub fn label(&self) -> &str {
229        self.hotp.label()
230    }
231
232    #[inline]
233    pub fn digits(&self) -> u8 {
234        self.hotp.digits()
235    }
236
237    #[inline]
238    pub fn secret(&self) -> &Secret {
239        self.hotp.secret()
240    }
241
242    /// Parses a TOTP configuration from a URI string in the [Key URI Format].
243    ///
244    /// This function supports URIs of the form:
245    /// `otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}`
246    ///
247    /// # Arguments
248    ///
249    /// * `uri` - A string slice containing the TOTP URI.
250    ///
251    /// # Returns
252    ///
253    /// Returns `Ok(Totp)` if the URI is valid and can be parsed. Otherwise returns `Err(Error)`
254    /// indicating the reason for failure.
255    ///
256    /// # Errors
257    ///
258    /// This method returns an error in the following cases:
259    ///
260    /// - URI does not start with the `otpauth://totp/` scheme.
261    /// - Missing or empty label in the URI.
262    /// - Missing or invalid query parameters (e.g., `secret`).
263    /// - Unsupported or invalid algorithm name.
264    /// - Base32 decoding of the secret fails.
265    /// - Convert string errors (e.g., `period`, `digits`).
266    /// - Invalid percent-encoding in the label or issuer.
267    ///
268    /// # Examples
269    ///
270    /// ```rust
271    /// use otp::Totp;
272    ///
273    /// let uri = "otpauth://totp/example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=example&algorithm=SHA1&digits=6&period=30";
274    /// let totp = Totp::from_uri(uri).unwrap();
275    /// assert_eq!(totp.issuer(), "example");
276    /// assert_eq!(totp.label(), "alice@example.com");
277    /// ```
278    ///
279    /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
280    pub fn from_uri(uri: &str) -> Result<Self, ParseUriError> {
281        let rest = uri
282            .strip_prefix("otpauth://totp/")
283            .ok_or(ParseUriError::InvalidPrefix)?;
284
285        let (label_encoded, queries) = rest.split_once('?').ok_or(ParseUriError::InvalidFormat)?;
286        if label_encoded.is_empty() {
287            return Err(ParseUriError::InvalidLabel);
288        }
289
290        let label_decoded =
291            encoding::url::decode(label_encoded).map_err(|_| ParseUriError::InvalidLabel)?;
292
293        let (issuer_from_label, label) =
294            if let Some((issuer, label)) = label_decoded.split_once(':') {
295                (Some(issuer), label.to_string())
296            } else {
297                (None, label_decoded)
298            };
299
300        let params: std::collections::HashMap<&str, &str> = queries
301            .split('&')
302            .map(|param| match param.split_once('=') {
303                Some((key, val)) => (key, val),
304                None => (param, ""),
305            })
306            .collect();
307
308        let digits = params.get("digits").map_or(Ok(6), |val| {
309            val.parse::<u8>().map_err(|_| ParseUriError::InvalidDigits)
310        })?;
311
312        let period = params.get("period").map_or(Ok(30), |val| {
313            val.parse::<u64>().map_err(|_| ParseUriError::InvalidPeriod)
314        })?;
315
316        let secret = params
317            .get("secret")
318            .ok_or(ParseUriError::MissingSecret)
319            .and_then(|raw_secret| {
320                Secret::from_base32(raw_secret).map_err(|_| ParseUriError::InvalidSecret)
321            })?;
322
323        let issuer_from_param = params
324            .get("issuer")
325            .map(|iss| encoding::url::decode(iss).map_err(|_| ParseUriError::InvalidIssuer))
326            .transpose()?;
327
328        let issuer = match (issuer_from_label, issuer_from_param) {
329            (None, None) => Ok(String::new()),
330            (None, Some(from_param)) => Ok(from_param),
331            (Some(from_label), None) => Ok(from_label.to_string()),
332            (Some(from_label), Some(from_param)) => {
333                if from_label != from_param {
334                    Err(ParseUriError::IssuerMismatch)
335                } else {
336                    Ok(from_param)
337                }
338            }
339        }?;
340
341        let alg = params
342            .get("algorithm")
343            .map(|alg| {
344                let alg = alg.to_uppercase();
345                match alg.as_str() {
346                    "SHA1" => Ok(Algorithm::SHA1),
347                    "SHA256" => Ok(Algorithm::SHA256),
348                    "SHA512" => Ok(Algorithm::SHA512),
349                    _ => Err(ParseUriError::InvalidAlgorithm),
350                }
351            })
352            .transpose()?;
353
354        Ok(Self::new(
355            alg.unwrap_or(Algorithm::SHA1),
356            issuer,
357            label,
358            digits,
359            period,
360            secret,
361        ))
362    }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum ParseUriError {
367    InvalidPrefix,
368    InvalidFormat,
369    InvalidLabel,
370    InvalidIssuer,
371    InvalidDigits,
372    InvalidPeriod,
373    InvalidSecret,
374    InvalidAlgorithm,
375    IssuerMismatch,
376    MissingSecret,
377}
378
379impl std::fmt::Display for ParseUriError {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        match self {
382            ParseUriError::InvalidPrefix => {
383                f.write_str("URI must start with 'otpauth://totp/'. Missing or incorrect prefix.")
384            }
385            ParseUriError::InvalidFormat => {
386                f.write_str("URI has an incorrect general format. Ensure it follows 'otpauth://type/label?parameters'.")
387            }
388            ParseUriError::InvalidLabel => {
389                f.write_str("The label (account name) in the URI is invalid or missing. Ensure it's properly encoded.")
390            }
391            ParseUriError::InvalidIssuer => {
392                f.write_str("The 'issuer' parameter is invalid or missing a value. Ensure it's present and correctly encoded.")
393            }
394            ParseUriError::InvalidDigits => {
395                f.write_str("The 'digits' parameter is invalid. It must be a positive integer, typically 6 or 8.")
396            }
397            ParseUriError::InvalidPeriod => {
398                f.write_str("The 'period' parameter is invalid. It must be a positive integer, typically 30 or 60.")
399            }
400            ParseUriError::InvalidSecret => {
401                f.write_str("The 'secret' parameter is invalid or not properly base32 encoded.")
402            }
403            ParseUriError::InvalidAlgorithm => {
404                f.write_str("The 'algorithm' parameter is invalid. Expected 'SHA1', 'SHA256', or 'SHA512'.")
405            }
406            ParseUriError::IssuerMismatch => {
407                f.write_str("The issuer specified in the label does not match the 'issuer' parameter.")
408            }
409            ParseUriError::MissingSecret => {
410                f.write_str("The 'secret' parameter is required but missing from the URI.")
411            }
412        }
413    }
414}
415
416impl std::error::Error for ParseUriError {}
417
418#[cfg(test)]
419impl Eq for Totp {}
420
421#[cfg(test)]
422impl PartialEq for Totp {
423    fn eq(&self, other: &Self) -> bool {
424        self.hotp == other.hotp && self.period == other.period
425    }
426}
427
428#[cfg(test)]
429impl std::fmt::Debug for Totp {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.debug_struct("Totp")
432            .field("hotp", &self.hotp)
433            .field("period", &self.period)
434            .finish()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_from_uri() {
444        let alg = Algorithm::SHA512;
445        let issuer = String::from("");
446        let label = String::from("alice@example.com");
447        let digits = 6;
448        let period = 30;
449        let secret = Secret::from_bytes(b"The quick brown fox jumps over the lazy dog");
450
451        let totp = Totp::new(alg, issuer, label, digits, period, secret);
452        let totp_uri = totp.to_uri();
453
454        let totp_from_uri = Totp::from_uri(&totp_uri).expect("should parse");
455
456        assert_eq!(totp_uri, totp_from_uri.to_uri(), "should generate same uri");
457        assert_eq!(totp, totp_from_uri, "should be equal");
458    }
459
460    #[test]
461    fn test_from_uri_with_invalid_prefix() {
462        let uri =
463            "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024";
464        let result = Totp::from_uri(uri);
465        assert!(
466            matches!(result, Err(ParseUriError::InvalidPrefix)),
467            "should be invalid prefix"
468        );
469    }
470}