]>
Commit | Line | Data |
---|---|---|
77dc52c0 | 1 | //! TOTP implementation. |
8cbf9cb7 WB |
2 | |
3 | use std::convert::TryFrom; | |
5c39559c | 4 | use std::error::Error as StdError; |
8cbf9cb7 WB |
5 | use std::fmt; |
6 | use std::time::{Duration, SystemTime}; | |
7 | ||
8cbf9cb7 WB |
8 | use openssl::hash::MessageDigest; |
9 | use openssl::pkey::PKey; | |
10 | use openssl::sign::Signer; | |
5c39559c | 11 | use percent_encoding::{percent_decode, percent_encode, PercentDecode}; |
8cbf9cb7 WB |
12 | use serde::{Serialize, Serializer}; |
13 | ||
5c39559c WB |
14 | /// An error from the TOTP TFA submodule. |
15 | #[derive(Debug)] | |
16 | pub enum Error { | |
17 | Generic(String), | |
18 | Decode(&'static str, Box<dyn StdError + Send + Sync + 'static>), | |
19 | BadParameter(String, Box<dyn StdError + Send + Sync + 'static>), | |
20 | Ssl(&'static str, openssl::error::ErrorStack), | |
21 | UnsupportedAlgorithm(String), | |
22 | UnknownParameter(String), | |
23 | } | |
24 | ||
25 | impl StdError for Error { | |
26 | fn source(&self) -> Option<&(dyn StdError + 'static)> { | |
27 | match self { | |
28 | Self::Ssl(_m, e) => Some(e), | |
29 | Self::Decode(_m, e) => Some(&**e), | |
30 | Self::BadParameter(_m, e) => Some(&**e), | |
31 | _ => None, | |
32 | } | |
33 | } | |
34 | } | |
35 | ||
36 | impl fmt::Display for Error { | |
37 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
38 | match self { | |
39 | Error::Generic(e) => f.write_str(&e), | |
40 | Error::Decode(m, _e) => f.write_str(&m), | |
41 | Error::Ssl(m, _e) => f.write_str(&m), | |
42 | Error::UnsupportedAlgorithm(a) => write!(f, "unsupported algorithm: '{a}'"), | |
43 | Error::UnknownParameter(p) => write!(f, "unknown otpauth uri parameter: '{p}'"), | |
44 | Error::BadParameter(m, _e) => f.write_str(&m), | |
45 | } | |
46 | } | |
47 | } | |
48 | ||
49 | impl Error { | |
50 | fn decode<E>(msg: &'static str, err: E) -> Self | |
51 | where | |
52 | E: StdError + Send + Sync + 'static, | |
53 | { | |
54 | Error::Decode(msg, Box::new(err)) | |
55 | } | |
56 | ||
57 | fn msg<T: fmt::Display>(err: T) -> Self { | |
58 | Error::Generic(err.to_string()) | |
59 | } | |
60 | } | |
61 | ||
62 | // for generic errors: | |
63 | macro_rules! format_err { | |
64 | ($($msg:tt)*) => {{ Error::Generic(format!($($msg)*)) }}; | |
65 | } | |
66 | ||
8cbf9cb7 WB |
67 | /// Algorithms supported by the TOTP. This is simply an enum limited to the most common |
68 | /// available implementations. | |
69 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] | |
70 | pub enum Algorithm { | |
71 | Sha1, | |
72 | Sha256, | |
73 | Sha512, | |
74 | } | |
75 | ||
d85ebbb4 WB |
76 | impl From<Algorithm> for MessageDigest { |
77 | fn from(algo: Algorithm) -> MessageDigest { | |
78 | match algo { | |
8cbf9cb7 WB |
79 | Algorithm::Sha1 => MessageDigest::sha1(), |
80 | Algorithm::Sha256 => MessageDigest::sha256(), | |
81 | Algorithm::Sha512 => MessageDigest::sha512(), | |
82 | } | |
83 | } | |
84 | } | |
85 | ||
86 | /// Displayed in a way compatible with the `otpauth` URI specification. | |
87 | impl fmt::Display for Algorithm { | |
88 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
89 | match self { | |
90 | Algorithm::Sha1 => write!(f, "SHA1"), | |
91 | Algorithm::Sha256 => write!(f, "SHA256"), | |
92 | Algorithm::Sha512 => write!(f, "SHA512"), | |
93 | } | |
94 | } | |
95 | } | |
96 | ||
97 | /// Parsed in a way compatible with the `otpauth` URI specification. | |
98 | impl std::str::FromStr for Algorithm { | |
99 | type Err = Error; | |
100 | ||
101 | fn from_str(s: &str) -> Result<Self, Error> { | |
102 | Ok(match s { | |
103 | "SHA1" => Algorithm::Sha1, | |
104 | "SHA256" => Algorithm::Sha256, | |
105 | "SHA512" => Algorithm::Sha512, | |
5c39559c | 106 | _ => return Err(Error::UnsupportedAlgorithm(s.to_string())), |
8cbf9cb7 WB |
107 | }) |
108 | } | |
109 | } | |
110 | ||
111 | /// OTP secret builder. | |
112 | #[derive(Clone, Debug, Eq, PartialEq)] | |
113 | #[repr(transparent)] | |
114 | pub struct TotpBuilder { | |
115 | inner: Totp, | |
116 | } | |
117 | ||
118 | impl From<Totp> for TotpBuilder { | |
119 | #[inline] | |
120 | fn from(inner: Totp) -> Self { | |
121 | Self { inner } | |
122 | } | |
123 | } | |
124 | ||
125 | impl TotpBuilder { | |
126 | pub fn secret(mut self, secret: Vec<u8>) -> Self { | |
127 | self.inner.secret = secret; | |
128 | self | |
129 | } | |
130 | ||
131 | /// Set the requested number of decimal digits. | |
132 | pub fn digits(mut self, digits: u8) -> Self { | |
133 | self.inner.digits = digits; | |
134 | self | |
135 | } | |
136 | ||
137 | /// Set the algorithm. | |
138 | pub fn algorithm(mut self, algorithm: Algorithm) -> Self { | |
139 | self.inner.algorithm = algorithm; | |
140 | self | |
141 | } | |
142 | ||
143 | /// Set the issuer. | |
144 | pub fn issuer(mut self, issuer: String) -> Self { | |
145 | self.inner.issuer = Some(issuer); | |
146 | self | |
147 | } | |
148 | ||
149 | /// Set the account name. This is required to create an URI. | |
150 | pub fn account_name(mut self, account_name: String) -> Self { | |
151 | self.inner.account_name = Some(account_name); | |
152 | self | |
153 | } | |
154 | ||
155 | /// Set the duration, in seconds, for which a value is valid. | |
156 | /// | |
157 | /// Panics if `seconds` is 0. | |
6255336f | 158 | pub fn period(mut self, seconds: usize) -> Self { |
8cbf9cb7 | 159 | if seconds == 0 { |
6255336f | 160 | panic!("zero as 'period' value is invalid"); |
8cbf9cb7 WB |
161 | } |
162 | ||
6255336f | 163 | self.inner.period = seconds; |
8cbf9cb7 WB |
164 | self |
165 | } | |
166 | ||
167 | /// Finalize the OTP instance. | |
168 | pub fn build(self) -> Totp { | |
169 | self.inner | |
170 | } | |
171 | } | |
172 | ||
173 | /// OTP secret key to produce OTP values with and the desired default number of decimal digits to | |
174 | /// use for its values (defaults to 6). | |
175 | #[derive(Clone, Debug, Eq, PartialEq)] | |
176 | pub struct Totp { | |
177 | /// The secret shared with the client. | |
178 | secret: Vec<u8>, | |
179 | ||
180 | /// The requested number decimal digits. | |
181 | digits: u8, | |
182 | ||
183 | /// The algorithm, defaults to sha1. | |
184 | algorithm: Algorithm, | |
185 | ||
186 | /// The duration, in seconds, for which a value is valid. Defaults to 30 seconds. | |
6255336f | 187 | period: usize, |
8cbf9cb7 WB |
188 | |
189 | /// An optional issuer. To help users identify their TOTP settings. | |
190 | issuer: Option<String>, | |
191 | ||
192 | /// An optional account name, possibly chosen by the user, to identify their TOTP settings. | |
193 | account_name: Option<String>, | |
194 | } | |
195 | ||
196 | impl Totp { | |
197 | /// Allow modifying parameters by turning this into a builder. | |
198 | pub fn into_builder(self) -> TotpBuilder { | |
199 | self.into() | |
200 | } | |
201 | ||
202 | /// Duplicate the value into a new builder to modify parameters. | |
203 | pub fn to_builder(&self) -> TotpBuilder { | |
204 | self.clone().into() | |
205 | } | |
206 | ||
207 | /// Create a new empty OTP instance with default values and a predefined secret key. | |
208 | pub fn empty() -> Self { | |
209 | Self { | |
210 | secret: Vec::new(), | |
211 | digits: 6, | |
212 | algorithm: Algorithm::Sha1, | |
6255336f | 213 | period: 30, |
8cbf9cb7 WB |
214 | issuer: None, |
215 | account_name: None, | |
216 | } | |
217 | } | |
218 | ||
219 | /// Create an OTP builder prefilled with default values. | |
220 | pub fn builder() -> TotpBuilder { | |
221 | TotpBuilder { | |
222 | inner: Self::empty(), | |
223 | } | |
224 | } | |
225 | ||
226 | /// Create a new OTP secret key builder using a secret specified in hexadecimal bytes. | |
227 | pub fn builder_from_hex(secret: &str) -> Result<TotpBuilder, Error> { | |
5c39559c WB |
228 | Ok(Self::builder().secret( |
229 | hex::decode(secret) | |
230 | .map_err(|err| Error::decode("not a valid hexademical string", err))?, | |
231 | )) | |
8cbf9cb7 WB |
232 | } |
233 | ||
234 | /// Get the secret key in binary form. | |
235 | pub fn secret(&self) -> &[u8] { | |
236 | &self.secret | |
237 | } | |
238 | ||
1554465d WB |
239 | /// Get the requested number of decimal digits. |
240 | pub fn digits(&self) -> u8 { | |
241 | self.digits | |
242 | } | |
243 | ||
8cbf9cb7 WB |
244 | /// Get the used algorithm. |
245 | pub fn algorithm(&self) -> Algorithm { | |
246 | self.algorithm | |
247 | } | |
248 | ||
6255336f WB |
249 | /// Get the period duration. |
250 | pub fn period(&self) -> Duration { | |
251 | Duration::from_secs(self.period as u64) | |
8cbf9cb7 WB |
252 | } |
253 | ||
254 | /// Get the issuer, if any. | |
255 | pub fn issuer(&self) -> Option<&str> { | |
97509b63 | 256 | self.issuer.as_deref() |
8cbf9cb7 WB |
257 | } |
258 | ||
259 | /// Get the account name, if any. | |
260 | pub fn account_name(&self) -> Option<&str> { | |
97509b63 | 261 | self.account_name.as_deref() |
8cbf9cb7 WB |
262 | } |
263 | ||
264 | /// Raw signing function. | |
265 | fn sign(&self, input_data: &[u8]) -> Result<TotpValue, Error> { | |
266 | let secret = PKey::hmac(&self.secret) | |
5c39559c | 267 | .map_err(|err| Error::Ssl("error instantiating hmac key", err))?; |
8cbf9cb7 WB |
268 | |
269 | let mut signer = Signer::new(self.algorithm.into(), &secret) | |
5c39559c | 270 | .map_err(|err| Error::Ssl("error instantiating hmac signer", err))?; |
8cbf9cb7 WB |
271 | |
272 | signer | |
273 | .update(input_data) | |
5c39559c | 274 | .map_err(|err| Error::Ssl("error updating hmac", err))?; |
8cbf9cb7 WB |
275 | |
276 | let hmac = signer | |
277 | .sign_to_vec() | |
5c39559c | 278 | .map_err(|err| Error::Ssl("error finishing hmac", err))?; |
8cbf9cb7 WB |
279 | |
280 | let byte_offset = usize::from( | |
281 | hmac.last() | |
5c39559c | 282 | .ok_or_else(|| format_err!("error calculating hmac (too short)"))? |
8cbf9cb7 WB |
283 | & 0xF, |
284 | ); | |
285 | ||
286 | let value = u32::from_be_bytes( | |
287 | TryFrom::try_from( | |
288 | hmac.get(byte_offset..(byte_offset + 4)) | |
5c39559c | 289 | .ok_or_else(|| format_err!("error finalizing hmac (too short)"))?, |
8cbf9cb7 WB |
290 | ) |
291 | .unwrap(), | |
292 | ) & 0x7fffffff; | |
293 | ||
294 | Ok(TotpValue { | |
295 | value, | |
296 | digits: u32::from(self.digits), | |
297 | }) | |
298 | } | |
299 | ||
300 | /// Create a HOTP value for a counter. | |
301 | /// | |
302 | /// This is currently private as for actual counter mode we should have a validate helper | |
303 | /// which forces handling of too-low-but-within-range values explicitly! | |
304 | fn counter(&self, count: u64) -> Result<TotpValue, Error> { | |
305 | self.sign(&count.to_be_bytes()) | |
306 | } | |
307 | ||
308 | /// Convert a time stamp into a counter value. This makes it easier and cheaper to check a | |
309 | /// range of values. | |
a3448feb | 310 | pub(crate) fn time_to_counter(&self, time: SystemTime) -> Result<u64, Error> { |
8cbf9cb7 | 311 | match time.duration_since(SystemTime::UNIX_EPOCH) { |
6255336f | 312 | Ok(epoch) => Ok(epoch.as_secs() / (self.period as u64)), |
5c39559c | 313 | Err(_) => Err(Error::msg("refusing to create otp value for negative time")), |
8cbf9cb7 WB |
314 | } |
315 | } | |
316 | ||
317 | /// Create a TOTP value for a time stamp. | |
318 | pub fn time(&self, time: SystemTime) -> Result<TotpValue, Error> { | |
319 | self.counter(self.time_to_counter(time)?) | |
320 | } | |
321 | ||
322 | /// Verify a time value within a range. | |
323 | /// | |
6255336f | 324 | /// This will iterate through `steps` and check if the provided `time + step * period_size` |
8cbf9cb7 WB |
325 | /// matches. If a match is found, the matching step will be returned. |
326 | pub fn verify( | |
327 | &self, | |
328 | digits: &str, | |
329 | time: SystemTime, | |
330 | steps: std::ops::RangeInclusive<isize>, | |
a3448feb | 331 | ) -> Result<Option<i64>, Error> { |
8cbf9cb7 WB |
332 | let count = self.time_to_counter(time)? as i64; |
333 | for step in steps { | |
a3448feb WB |
334 | let count = count + step as i64; |
335 | if self.counter(count as u64)? == digits { | |
336 | return Ok(Some(count)); | |
8cbf9cb7 WB |
337 | } |
338 | } | |
339 | Ok(None) | |
340 | } | |
341 | ||
342 | /// Create an otpauth URI for this configuration. | |
343 | pub fn to_uri(&self) -> Result<String, Error> { | |
344 | use std::fmt::Write; | |
345 | ||
346 | let mut out = String::new(); | |
347 | ||
5c39559c | 348 | write!(out, "otpauth://totp/").map_err(Error::msg)?; |
8cbf9cb7 WB |
349 | |
350 | let account_name = match &self.account_name { | |
351 | Some(account_name) => account_name, | |
5c39559c WB |
352 | None => { |
353 | return Err(Error::msg( | |
354 | "cannot create otpauth uri without an account name", | |
355 | )) | |
356 | } | |
8cbf9cb7 WB |
357 | }; |
358 | ||
359 | let issuer = match &self.issuer { | |
360 | Some(issuer) => { | |
361 | let issuer = percent_encode(issuer.as_bytes(), percent_encoding::NON_ALPHANUMERIC) | |
362 | .to_string(); | |
5c39559c | 363 | write!(out, "{}:", issuer).map_err(Error::msg)?; |
8cbf9cb7 WB |
364 | Some(issuer) |
365 | } | |
366 | None => None, | |
367 | }; | |
368 | ||
369 | write!( | |
370 | out, | |
371 | "{}?secret={}", | |
372 | percent_encode(account_name.as_bytes(), percent_encoding::NON_ALPHANUMERIC), | |
373 | base32::encode(base32::Alphabet::RFC4648 { padding: false }, &self.secret), | |
5c39559c WB |
374 | ) |
375 | .map_err(Error::msg)?; | |
376 | write!(out, "&digits={}", self.digits).map_err(Error::msg)?; | |
377 | write!(out, "&algorithm={}", self.algorithm).map_err(Error::msg)?; | |
378 | write!(out, "&period={}", self.period).map_err(Error::msg)?; | |
8cbf9cb7 WB |
379 | |
380 | if let Some(issuer) = issuer { | |
5c39559c | 381 | write!(out, "&issuer={}", issuer).map_err(Error::msg)?; |
8cbf9cb7 WB |
382 | } |
383 | ||
384 | Ok(out) | |
385 | } | |
386 | } | |
387 | ||
388 | impl std::str::FromStr for Totp { | |
389 | type Err = Error; | |
390 | ||
391 | fn from_str(uri: &str) -> Result<Self, Error> { | |
392 | if !uri.starts_with("otpauth://totp/") { | |
5c39559c | 393 | return Err(Error::msg("not an otpauth uri")); |
8cbf9cb7 WB |
394 | } |
395 | ||
396 | let uri = &uri.as_bytes()[15..]; | |
397 | let qmark = uri | |
398 | .iter() | |
399 | .position(|&b| b == b'?') | |
5c39559c | 400 | .ok_or_else(|| format_err!("missing '?' in otp uri"))?; |
8cbf9cb7 WB |
401 | |
402 | let account = &uri[..qmark]; | |
403 | let uri = &uri[(qmark + 1)..]; | |
404 | ||
405 | // FIXME: Also split on "%3A" / "%3a" | |
406 | let mut account = account.splitn(2, |&b| b == b':'); | |
407 | let first_part = percent_decode( | |
d85ebbb4 | 408 | account |
8cbf9cb7 | 409 | .next() |
5c39559c | 410 | .ok_or_else(|| format_err!("missing account in otpauth uri"))?, |
8cbf9cb7 WB |
411 | ) |
412 | .decode_utf8_lossy() | |
413 | .into_owned(); | |
414 | ||
415 | let mut totp = Totp::empty(); | |
416 | ||
417 | match account.next() { | |
418 | Some(account_name) => { | |
419 | totp.issuer = Some(first_part); | |
420 | totp.account_name = | |
421 | Some(percent_decode(account_name).decode_utf8_lossy().to_string()); | |
422 | } | |
423 | None => totp.account_name = Some(first_part), | |
424 | } | |
425 | ||
426 | for parts in uri.split(|&b| b == b'&') { | |
427 | let mut parts = parts.splitn(2, |&b| b == b'='); | |
5c39559c | 428 | |
8cbf9cb7 | 429 | let key = percent_decode( |
d85ebbb4 | 430 | parts |
8cbf9cb7 | 431 | .next() |
5c39559c | 432 | .ok_or_else(|| format_err!("bad key in otpauth uri"))?, |
8cbf9cb7 | 433 | ) |
5c39559c WB |
434 | .decode_utf8() |
435 | .map_err(|err| Error::decode("failed to decode key", err))?; | |
436 | ||
8cbf9cb7 | 437 | let value = percent_decode( |
d85ebbb4 | 438 | parts |
8cbf9cb7 | 439 | .next() |
5c39559c | 440 | .ok_or_else(|| format_err!("bad value in otpauth uri"))?, |
8cbf9cb7 WB |
441 | ); |
442 | ||
5c39559c WB |
443 | fn decode_utf8<T>(value: PercentDecode, n: &'static str) -> Result<T, Error> |
444 | where | |
445 | T: std::str::FromStr, | |
446 | T::Err: StdError + Send + Sync + 'static, | |
447 | { | |
448 | value | |
449 | .decode_utf8() | |
450 | .map_err(|err| { | |
451 | Error::BadParameter(format!("failed to decode value '{n}'"), Box::new(err)) | |
452 | })? | |
453 | .parse() | |
454 | .map_err(|err| { | |
455 | Error::BadParameter(format!("failed to parse value '{n}'"), Box::new(err)) | |
456 | }) | |
457 | } | |
458 | ||
8cbf9cb7 WB |
459 | match &*key { |
460 | "secret" => { | |
461 | totp.secret = base32::decode( | |
462 | base32::Alphabet::RFC4648 { padding: false }, | |
5c39559c WB |
463 | &value |
464 | .decode_utf8() | |
465 | .map_err(|err| Error::decode("failed to decode value", err))?, | |
8cbf9cb7 | 466 | ) |
5c39559c | 467 | .ok_or_else(|| format_err!("failed to decode otp secret in otpauth url"))? |
8cbf9cb7 | 468 | } |
5c39559c WB |
469 | "digits" => totp.digits = decode_utf8(value, "digits")?, |
470 | "algorithm" => totp.algorithm = decode_utf8(value, "algorithm")?, | |
471 | "period" => totp.period = decode_utf8(value, "period")?, | |
8cbf9cb7 | 472 | "issuer" => totp.issuer = Some(value.decode_utf8_lossy().into_owned()), |
5c39559c | 473 | _other => return Err(Error::UnknownParameter(key.to_string())), |
8cbf9cb7 WB |
474 | } |
475 | } | |
476 | ||
477 | if totp.secret.is_empty() { | |
5c39559c | 478 | return Err(Error::msg("missing secret in otpauth url")); |
8cbf9cb7 WB |
479 | } |
480 | ||
481 | Ok(totp) | |
482 | } | |
483 | } | |
484 | ||
77dc52c0 | 485 | serde_plain::derive_deserialize_from_fromstr!(Totp, "valid TOTP url"); |
8cbf9cb7 WB |
486 | |
487 | impl Serialize for Totp { | |
488 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | |
489 | where | |
490 | S: Serializer, | |
491 | { | |
492 | use serde::ser::Error; | |
493 | ||
494 | serializer.serialize_str( | |
495 | &self | |
496 | .to_uri() | |
497 | .map_err(|err| Error::custom(err.to_string()))?, | |
498 | ) | |
499 | } | |
500 | } | |
501 | ||
502 | /// A HOTP value with a decimal digit limit. | |
503 | #[derive(Clone, Copy, Debug)] | |
504 | pub struct TotpValue { | |
505 | value: u32, | |
506 | digits: u32, | |
507 | } | |
508 | ||
509 | impl TotpValue { | |
510 | /// Change the number of decimal digits used for this HOTP value. | |
511 | pub fn digits(self, digits: u32) -> Self { | |
512 | Self { digits, ..self } | |
513 | } | |
514 | ||
515 | /// Get the raw integer value before truncation. | |
516 | pub fn raw(&self) -> u32 { | |
517 | self.value | |
518 | } | |
519 | ||
520 | /// Get the integer value truncated to the requested number of decimal digits. | |
521 | pub fn value(&self) -> u32 { | |
522 | self.value % 10u32.pow(self.digits) | |
523 | } | |
524 | } | |
525 | ||
526 | impl fmt::Display for TotpValue { | |
527 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
528 | write!( | |
529 | f, | |
530 | "{0:0width$}", | |
531 | self.value(), | |
532 | width = (self.digits as usize) | |
533 | ) | |
534 | } | |
535 | } | |
536 | ||
537 | impl PartialEq<u32> for TotpValue { | |
538 | fn eq(&self, other: &u32) -> bool { | |
539 | self.value() == *other | |
540 | } | |
541 | } | |
542 | ||
543 | /// For convenience we allow directly comparing with a string. This will make sure the string has | |
544 | /// the exact number of digits while parsing it explicitly as a decimal string. | |
545 | impl PartialEq<&str> for TotpValue { | |
546 | fn eq(&self, other: &&str) -> bool { | |
547 | // Since we use `from_str_radix` with a radix of 10 explicitly, we can check the number of | |
548 | // bytes against the number of digits. | |
549 | if other.as_bytes().len() != (self.digits as usize) { | |
550 | return false; | |
551 | } | |
552 | ||
d85ebbb4 WB |
553 | // I don't trust that `.parse()` never starts accepting `0x` prefixes so: |
554 | #[allow(clippy::from_str_radix_10)] | |
d396c3ea | 555 | match u32::from_str_radix(other, 10) { |
8cbf9cb7 WB |
556 | Ok(value) => self.value() == value, |
557 | Err(_) => false, | |
558 | } | |
559 | } | |
560 | } | |
561 | ||
562 | #[test] | |
563 | fn test_otp() { | |
564 | // Validated via: | |
565 | // ```sh | |
566 | // $ oathtool --hotp -c1 87259aa6550f059bca8c | |
567 | // 337037 | |
568 | // ``` | |
569 | const SECRET_1: &str = "87259aa6550f059bca8c"; | |
570 | const EXPECTED_1: &str = "337037"; | |
571 | const EXPECTED_2: &str = "296746"; | |
572 | const EXPECTED_3: &str = "251167"; | |
573 | const EXPECTED_4_D8: &str = "11899249"; | |
574 | ||
575 | let hotp = Totp::builder_from_hex(SECRET_1) | |
576 | .expect("failed to create Totp key") | |
577 | .digits(6) | |
578 | .build(); | |
579 | assert_eq!( | |
580 | hotp.counter(1).expect("failed to create hotp value"), | |
581 | EXPECTED_1, | |
582 | ); | |
583 | assert_eq!( | |
584 | hotp.counter(2) | |
585 | .expect("failed to create hotp value") | |
586 | .digits(6), | |
587 | EXPECTED_2, | |
588 | ); | |
589 | assert_eq!( | |
590 | hotp.counter(3) | |
591 | .expect("failed to create hotp value") | |
592 | .digits(6), | |
593 | EXPECTED_3, | |
594 | ); | |
595 | assert_eq!( | |
596 | hotp.counter(4) | |
597 | .expect("failed to create hotp value") | |
598 | .digits(8), | |
599 | EXPECTED_4_D8, | |
600 | ); | |
601 | ||
602 | let hotp = hotp | |
603 | .into_builder() | |
604 | .account_name("My Account".to_string()) | |
605 | .build(); | |
606 | let uri = hotp.to_uri().expect("failed to create otpauth uri"); | |
607 | let parsed: Totp = uri.parse().expect("failed to parse otp uri"); | |
608 | assert_eq!(parsed, hotp); | |
609 | assert_eq!(parsed.issuer, None); | |
6f8173f6 | 610 | assert_eq!(parsed.account_name.as_deref(), Some("My Account")); |
8cbf9cb7 WB |
611 | |
612 | const SECRET_2: &str = "a60b1b20679b1a64e21a"; | |
613 | const EXPECTED: &str = "7757717"; | |
614 | // Validated via: | |
615 | // ```sh | |
616 | // $ oathtool --totp -d7 -s30 --now='2020-08-04 15:14:23 UTC' a60b1b20679b1a64e21a | |
617 | // 7757717 | |
618 | // $ date -d'2020-08-04 15:14:23 UTC' +%s | |
619 | // 1596554063 | |
620 | // ``` | |
621 | // | |
622 | let totp = Totp::builder_from_hex(SECRET_2) | |
623 | .expect("failed to create Totp key") | |
624 | .build(); | |
625 | assert_eq!( | |
626 | totp.time(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1596554063)) | |
627 | .expect("failed to create totp value") | |
628 | .digits(7), | |
629 | EXPECTED, | |
630 | ); | |
631 | ||
632 | let totp = totp | |
633 | .into_builder() | |
634 | .account_name("The Account Name".to_string()) | |
635 | .issuer("An Issuer".to_string()) | |
636 | .build(); | |
637 | let uri = totp.to_uri().expect("failed to create otpauth uri"); | |
638 | let parsed: Totp = uri.parse().expect("failed to parse otp uri"); | |
639 | assert_eq!(parsed, totp); | |
6f8173f6 TL |
640 | assert_eq!(parsed.issuer.as_deref(), Some("An Issuer")); |
641 | assert_eq!(parsed.account_name.as_deref(), Some("The Account Name")); | |
8cbf9cb7 | 642 | } |