Skip to main content

otp/
hotp.rs

1use crate::{Algorithm, Secret, encoding, hmac};
2
3pub struct Hotp {
4    alg: Algorithm,
5    issuer: String,
6    label: String,
7    digits: u8,
8    counter: u64,
9    secret: Secret,
10}
11
12impl Default for Hotp {
13    fn default() -> Self {
14        Self {
15            alg: Algorithm::default(),
16            issuer: String::new(),
17            label: String::new(),
18            digits: 6,
19            counter: 0,
20            secret: Default::default(),
21        }
22    }
23}
24
25impl Hotp {
26    /// Creates a new [`Hotp`] instance with the specified configuration.
27    ///
28    /// # Arguments
29    ///
30    /// * `alg` - The hashing algorithm to use (e.g., [`Algorithm::SHA1`], [`Algorithm::SHA256`], or [`Algorithm::SHA512`]).
31    /// * `issuer` - The name of the service or provider (e.g., `"GitHub"` or `"example.com"`).
32    /// * `label` - An identifier for the user account (e.g., `"alice@example.com"`).
33    /// * `digits` - Number of digits in the generated OTP (typically 6 or 8).
34    /// * `counter` - Initial counter value for HOTP generation.
35    /// * `secret` - The shared secret key used to generate the HMAC.
36    ///
37    /// # Returns
38    ///
39    /// Returns a new instance of [`Hotp`] configured with the provided parameters.
40    ///
41    /// # Example
42    ///
43    /// ```rust
44    /// use otp::{Hotp, Algorithm, Secret};
45    ///
46    /// let hotp = Hotp::new(
47    ///     Algorithm::SHA1,
48    ///     "example".into(),
49    ///     "alice@example.com".into(),
50    ///     6,
51    ///     0,
52    ///     Secret::from_bytes(b"supersecret"),
53    /// );
54    /// ```
55    pub fn new(
56        alg: Algorithm,
57        issuer: String,
58        label: String,
59        digits: u8,
60        counter: u64,
61        secret: Secret,
62    ) -> Self {
63        Self {
64            alg,
65            issuer,
66            label,
67            digits,
68            counter,
69            secret,
70        }
71    }
72
73    /// Generates the next OTP value and increments the internal counter.
74    ///
75    /// This method uses the current counter value, produces a new HOTP code,
76    /// then advances the internal counter by one.
77    ///
78    /// Internally uses `generate_at` and follows the [HOTP Algorithm]
79    /// specified in [RFC 4226].
80    ///
81    /// # Returns
82    /// A numeric HOTP code as a `u32`.
83    ///
84    /// # Example
85    /// ```rust
86    /// let mut hotp = otp::Hotp::default();
87    /// let otp = hotp.generate();
88    /// println!("OTP: {}", otp);
89    /// ```
90    ///
91    /// [HOTP Algorithm]: <https://datatracker.ietf.org/doc/html/rfc4226>
92    /// [RFC 4226]: <https://datatracker.ietf.org/doc/html/rfc4226#section-5.3>
93    pub fn generate(&mut self) -> u32 {
94        let otp = self.generate_at(self.counter);
95        self.counter += 1;
96        otp
97    }
98
99    /// Generates an OTP value at a specific counter value, without modifying internal state.
100    ///
101    /// This method is useful for verifying or regenerating a known HOTP value at a given counter.
102    ///
103    /// It uses HMAC with the configured algorithm (SHA-1, SHA-256, etc.), then applies dynamic
104    /// truncation as described in [RFC 4226].
105    ///
106    /// # Arguments
107    /// * `counter` - The counter value at which to generate the OTP
108    ///
109    /// # Returns
110    /// A numeric OTP code as a `u32`.
111    ///
112    /// # Example
113    /// ```rust
114    /// let hotp = otp::Hotp::default();
115    /// let otp = hotp.generate_at(1234);
116    /// ```
117    ///
118    /// # References
119    /// - [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226#section-5.3)
120    pub fn generate_at(&self, counter: u64) -> u32 {
121        let message = counter.to_be_bytes();
122
123        let hmac_result = hmac(self.alg, self.secret.as_bytes(), &message);
124
125        let offset = (hmac_result[hmac_result.len() - 1] & 0x0f) as usize;
126
127        let code = ((u32::from(hmac_result[offset]) & 0x7f) << 24)
128            | (u32::from(hmac_result[offset + 1]) << 16)
129            | (u32::from(hmac_result[offset + 2]) << 8)
130            | u32::from(hmac_result[offset + 3]);
131
132        code % 10_u32.pow(self.digits as u32)
133    }
134
135    /// Verifies a provided OTP code against a given counter value, allowing for a window of flexibility.
136    ///
137    /// This method compares the given `otp` with the expected values generated
138    /// at `counter - window` to `counter + window`. This accounts for clock drift
139    /// or synchronization delays.
140    ///
141    /// # Arguments
142    /// * `otp`     - The OTP code to verify
143    /// * `counter` - The current known counter (typically stored server-side)
144    /// * `window`  - How many counter steps before and after to check
145    ///
146    /// # Returns
147    /// `true` if a match is found within the window range, `false` otherwise.
148    ///
149    /// # Example
150    /// ```rust
151    /// let hotp = otp::Hotp::default();
152    /// let otp = hotp.generate_at(5);
153    /// assert!(hotp.verify(otp, 5, 1)); // exact match
154    /// assert!(hotp.verify(otp, 6, 1)); // match in past window
155    /// assert!(!hotp.verify(otp, 10, 2)); // out of range
156    /// ```
157    ///
158    /// # References
159    /// - [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226#section-5.4)
160    pub fn verify(&self, otp: u32, counter: u64, window: u64) -> bool {
161        if self.generate_at(counter) == otp {
162            return true;
163        }
164
165        for i in 1..=window {
166            if counter >= i && self.generate_at(counter - i) == otp {
167                return true;
168            }
169            if self.generate_at(counter + i) == otp {
170                return true;
171            }
172        }
173
174        false
175    }
176
177    /// Generates a Key URI string in the format compatible with Google Authenticator and other TOTP/HOTP apps.
178    ///
179    /// This URI can be encoded as a QR code and scanned by authenticator apps (e.g., Google Authenticator, Authy)
180    /// to configure the OTP settings automatically.
181    ///
182    /// The URI format follows the [Key URI Format] specification:
183    ///
184    /// ```text
185    /// otpauth://TYPE/LABEL?PARAMETERS
186    /// ```
187    ///
188    /// For example, a TOTP URI might look like:
189    ///
190    /// ```text
191    /// otpauth://totp/Example%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
192    /// ```
193    ///
194    /// # Format Details
195    /// - `TYPE`: Either `totp` or `hotp`
196    /// - `LABEL`: Usually `issuer:account`, URL-encoded
197    /// - `secret`: Base32-encoded secret key
198    /// - `issuer`: The provider or service name (optional, but recommended)
199    /// - `algorithm`: Hash function used (e.g., SHA1, SHA256, SHA512)
200    /// - `digits`: Number of digits in the OTP (typically 6 or 8)
201    /// - `period` (TOTP only): Time step in seconds (e.g., 30)
202    /// - `counter` (HOTP only): Current counter value
203    ///
204    /// # Returns
205    /// A `String` containing the `otpauth://` URI.
206    ///
207    /// # Example
208    /// ```rust
209    /// use otp::{Totp, Algorithm, Secret};
210    ///
211    /// let totp = Totp::new(
212    ///     Algorithm::SHA256,
213    ///     "Example".into(),
214    ///     "alice@example.com".into(),
215    ///     6,
216    ///     30,
217    ///     Secret::from_bytes(b"supersecretkey")
218    /// );
219    ///
220    /// let uri = totp.to_uri();
221    /// assert!(uri.starts_with("otpauth://totp/"));
222    /// ```
223    ///
224    /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
225    pub fn to_uri(&self) -> String {
226        let secret = self.secret.into_base32();
227        let label = if self.issuer().is_empty() {
228            encoding::url::encode(self.label().as_bytes())
229        } else {
230            encoding::url::encode(format!("{}:{}", &self.issuer(), &self.label()).as_bytes())
231        };
232        let issuer = if !self.issuer().is_empty() {
233            format!(
234                "&issuer={}",
235                encoding::url::encode(self.issuer().as_bytes())
236            )
237        } else {
238            String::new()
239        };
240        let digits = self.digits;
241        let counter = self.counter;
242        let alg = self.alg.to_string();
243
244        format!(
245            "otpauth://hotp/{label}?secret={secret}{issuer}&algorithm={alg}&digits={digits}&counter={counter}"
246        )
247    }
248
249    #[inline]
250    pub fn alg(&self) -> Algorithm {
251        self.alg
252    }
253
254    #[inline]
255    pub fn issuer(&self) -> &str {
256        &self.issuer
257    }
258
259    #[inline]
260    pub fn label(&self) -> &str {
261        &self.label
262    }
263
264    #[inline]
265    pub fn digits(&self) -> u8 {
266        self.digits
267    }
268
269    #[inline]
270    pub fn counter(&self) -> u64 {
271        self.counter
272    }
273
274    #[inline]
275    pub fn secret(&self) -> &Secret {
276        &self.secret
277    }
278
279    /// Parses a HOTP configuration from a URI string in the [Key URI Format].
280    ///
281    /// This function supports URIs of the form:
282    /// `otpauth://hotp/{label}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&counter={counter}`
283    ///
284    /// # Arguments
285    ///
286    /// * `uri` - A string slice containing the HOTP URI.
287    ///
288    /// # Returns
289    ///
290    /// Returns `Ok(Hotp)` if the URI is valid and can be parsed. Otherwise returns `Err(Error)`
291    /// indicating the reason for failure.
292    ///
293    /// # Errors
294    ///
295    /// This method returns an error in the following cases:
296    ///
297    /// - URI does not start with the `otpauth://hotp/` scheme.
298    /// - Missing or empty label in the URI.
299    /// - Missing or invalid query parameters (e.g., `secret`, `counter`).
300    /// - Unsupported or invalid algorithm name.
301    /// - Base32 decoding of the secret fails.
302    /// - Convert string errors (e.g., `counter`, `digits`).
303    /// - Invalid percent-encoding in the label or issuer.
304    ///
305    /// # Examples
306    ///
307    /// ```rust
308    /// use otp::Hotp;
309    ///
310    /// let uri = "otpauth://hotp/example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=example&algorithm=SHA1&digits=6&counter=1";
311    /// let hotp = Hotp::from_uri(uri).unwrap();
312    /// assert_eq!(hotp.issuer(), "example");
313    /// assert_eq!(hotp.label(), "alice@example.com");
314    /// ```
315    ///
316    /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
317    pub fn from_uri(uri: &str) -> Result<Self, ParseUriError> {
318        let rest = uri
319            .strip_prefix("otpauth://hotp/")
320            .ok_or(ParseUriError::InvalidPrefix)?;
321
322        let (label_encoded, queries) = rest.split_once('?').ok_or(ParseUriError::InvalidFormat)?;
323        if label_encoded.is_empty() {
324            return Err(ParseUriError::InvalidLabel);
325        }
326
327        let label_decoded =
328            encoding::url::decode(label_encoded).map_err(|_| ParseUriError::InvalidLabel)?;
329
330        let (issuer_from_label, label) =
331            if let Some((issuer, label)) = label_decoded.split_once(':') {
332                (Some(issuer), label.to_string())
333            } else {
334                (None, label_decoded)
335            };
336
337        let params: std::collections::HashMap<&str, &str> = queries
338            .split('&')
339            .map(|param| match param.split_once('=') {
340                Some((key, val)) => (key, val),
341                None => (param, ""),
342            })
343            .collect();
344
345        let digits = params.get("digits").map_or(Ok(6), |val| {
346            val.parse::<u8>().map_err(|_| ParseUriError::InvalidDigits)
347        })?;
348
349        let counter = params
350            .get("counter")
351            .ok_or(ParseUriError::MissingCounter)
352            .and_then(|val| {
353                val.parse::<u64>()
354                    .map_err(|_| ParseUriError::InvalidCounter)
355            })?;
356
357        let secret = params
358            .get("secret")
359            .ok_or(ParseUriError::MissingSecret)
360            .and_then(|raw_secret| {
361                Secret::from_base32(raw_secret).map_err(|_| ParseUriError::InvalidSecret)
362            })?;
363
364        let issuer_from_param = params
365            .get("issuer")
366            .map(|iss| encoding::url::decode(iss).map_err(|_| ParseUriError::InvalidIssuer))
367            .transpose()?;
368
369        let issuer = match (issuer_from_label, issuer_from_param) {
370            (None, None) => Ok(String::new()),
371            (None, Some(from_param)) => Ok(from_param),
372            (Some(from_label), None) => Ok(from_label.to_string()),
373            (Some(from_label), Some(from_param)) => {
374                if from_label != from_param {
375                    Err(ParseUriError::IssuerMismatch)
376                } else {
377                    Ok(from_param)
378                }
379            }
380        }?;
381
382        let alg = params
383            .get("algorithm")
384            .map(|alg| {
385                let alg = alg.to_uppercase();
386                match alg.as_str() {
387                    "SHA1" => Ok(Algorithm::SHA1),
388                    "SHA256" => Ok(Algorithm::SHA256),
389                    "SHA512" => Ok(Algorithm::SHA512),
390                    _ => Err(ParseUriError::InvalidAlgorithm),
391                }
392            })
393            .transpose()?;
394
395        Ok(Self::new(
396            alg.unwrap_or(Algorithm::SHA1),
397            issuer,
398            label,
399            digits,
400            counter,
401            secret,
402        ))
403    }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum ParseUriError {
408    InvalidPrefix,
409    InvalidFormat,
410    InvalidLabel,
411    InvalidIssuer,
412    InvalidDigits,
413    InvalidCounter,
414    InvalidSecret,
415    InvalidAlgorithm,
416    IssuerMismatch,
417    MissingSecret,
418    MissingCounter,
419}
420
421impl std::fmt::Display for ParseUriError {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        match self {
424            ParseUriError::InvalidPrefix => {
425                        f.write_str("URI must start with 'otpauth://hotp/'. Missing or incorrect prefix.")
426                    }
427            ParseUriError::InvalidFormat => {
428                        f.write_str("URI has an incorrect general format. Ensure it follows 'otpauth://type/label?parameters'.")
429                    }
430            ParseUriError::InvalidLabel => {
431                        f.write_str("The label (account name) in the URI is invalid or missing. Ensure it's properly encoded.")
432                    }
433            ParseUriError::InvalidIssuer => {
434                        f.write_str("The 'issuer' parameter is invalid or missing a value. Ensure it's present and correctly encoded.")
435                    }
436            ParseUriError::InvalidDigits => {
437                        f.write_str("The 'digits' parameter is invalid. It must be a positive integer, typically 6 or 8.")
438                    }
439            ParseUriError::InvalidSecret => {
440                        f.write_str("The 'secret' parameter is invalid or not properly base32 encoded.")
441                    }
442            ParseUriError::InvalidAlgorithm => {
443                        f.write_str("The 'algorithm' parameter is invalid. Expected 'SHA1', 'SHA256', or 'SHA512'.")
444                    }
445            ParseUriError::IssuerMismatch => {
446                        f.write_str("The issuer specified in the label does not match the 'issuer' parameter.")
447                    }
448            ParseUriError::MissingSecret => {
449                        f.write_str("The 'secret' parameter is required but missing from the URI.")
450                    }
451            ParseUriError::InvalidCounter => {
452                        f.write_str("The 'counter' parameter is invalid. It must be a positive integer.")
453            },
454            ParseUriError::MissingCounter => {
455                        f.write_str("The 'counter' parameter is required but missing from the URI.")
456            },
457        }
458    }
459}
460
461impl std::error::Error for ParseUriError {}
462
463#[cfg(test)]
464impl Eq for Hotp {}
465
466#[cfg(test)]
467impl PartialEq for Hotp {
468    fn eq(&self, other: &Self) -> bool {
469        self.alg == other.alg
470            && self.issuer == other.issuer
471            && self.label == other.label
472            && self.digits == other.digits
473            && self.secret == other.secret
474    }
475}
476
477#[cfg(test)]
478impl std::fmt::Debug for Hotp {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        f.debug_struct("Hotp")
481            .field("alg", &self.alg.to_string())
482            .field("issuer", &self.issuer)
483            .field("label", &self.label)
484            .field("digits", &self.digits)
485            .field("counter", &self.counter)
486            .field("secret", &self.secret)
487            .finish()
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_from_uri() {
497        let alg = Algorithm::SHA512;
498        let issuer = String::from("example");
499        let label = String::from("alice@example.com");
500        let digits = 6;
501        let counter = 0;
502        let secret = Secret::from_bytes(b"The quick brown fox jumps over the lazy dog");
503
504        let hotp = Hotp::new(alg, issuer, label, digits, counter, secret);
505        let hotp_uri = hotp.to_uri();
506
507        let hotp_from_uri = Hotp::from_uri(&hotp_uri).expect("parse error");
508
509        assert_eq!(hotp_uri, hotp_from_uri.to_uri(), "should have same uri");
510        assert_eq!(hotp, hotp_from_uri, "should be equal");
511    }
512
513    #[test]
514    fn test_from_uri_with_invalid_prefix() {
515        let uri =
516            "otpauth://totp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024";
517        let result = Hotp::from_uri(uri);
518        assert!(
519            matches!(result, Err(ParseUriError::InvalidPrefix)),
520            "should be invalid prefix"
521        );
522    }
523
524    #[test]
525    fn test_from_uri_with_missing_counter() {
526        let uri =
527            "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024";
528        let result = Hotp::from_uri(uri);
529        assert!(
530            matches!(result, Err(ParseUriError::MissingCounter)),
531            "should be missing counter"
532        );
533    }
534
535    #[test]
536    fn test_from_uri_with_missing_secret() {
537        let uri = "otpauth://hotp/issuer:alice@example.com?algorithm=SHA1024&counter=69420";
538        let result = Hotp::from_uri(uri);
539        assert!(
540            matches!(result, Err(ParseUriError::MissingSecret)),
541            "should be missing secret"
542        );
543    }
544
545    #[test]
546    fn test_from_uri_with_invalid_algorithm() {
547        let uri = "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024&counter=69";
548        let result = Hotp::from_uri(uri);
549        assert!(
550            matches!(result, Err(ParseUriError::InvalidAlgorithm)),
551            "should be invalid algorithm"
552        );
553    }
554
555    #[test]
556    fn test_from_uri_with_invalid_uri_encoding() {
557        let uri = "otpauth://hotp/issuer%ZZ:alice@example.com?secret=JBSWY3DPEHPK3PXP";
558        let result = Hotp::from_uri(uri);
559        assert!(
560            matches!(result, Err(ParseUriError::InvalidLabel)),
561            "should be invalid label"
562        );
563    }
564
565    #[test]
566    fn test_from_uri_with_issuer_mismatch() {
567        let uri = "otpauth://hotp/javascript:alice@example.com?secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust";
568        let result = Hotp::from_uri(uri);
569        assert!(matches!(result, Err(ParseUriError::IssuerMismatch)));
570    }
571
572    #[test]
573    fn test_from_uri_with_invalid_format() {
574        let uri = "otpauth://hotp/javascript:alice@example.com&secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust";
575        let result = Hotp::from_uri(uri);
576        assert!(matches!(result, Err(ParseUriError::InvalidFormat)));
577    }
578}