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}