]> git.proxmox.com Git - proxmox.git/blame - proxmox-tfa/src/totp.rs
tfa: log all tfa verify errors and treat as failure, count
[proxmox.git] / proxmox-tfa / src / totp.rs
CommitLineData
77dc52c0 1//! TOTP implementation.
8cbf9cb7
WB
2
3use std::convert::TryFrom;
5c39559c 4use std::error::Error as StdError;
8cbf9cb7
WB
5use std::fmt;
6use std::time::{Duration, SystemTime};
7
8cbf9cb7
WB
8use openssl::hash::MessageDigest;
9use openssl::pkey::PKey;
10use openssl::sign::Signer;
5c39559c 11use percent_encoding::{percent_decode, percent_encode, PercentDecode};
8cbf9cb7
WB
12use serde::{Serialize, Serializer};
13
5c39559c
WB
14/// An error from the TOTP TFA submodule.
15#[derive(Debug)]
16pub 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
25impl 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
36impl 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
49impl 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:
63macro_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)]
70pub enum Algorithm {
71 Sha1,
72 Sha256,
73 Sha512,
74}
75
d85ebbb4
WB
76impl 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.
87impl 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.
98impl 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)]
114pub struct TotpBuilder {
115 inner: Totp,
116}
117
118impl From<Totp> for TotpBuilder {
119 #[inline]
120 fn from(inner: Totp) -> Self {
121 Self { inner }
122 }
123}
124
125impl 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)]
176pub 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
196impl 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
388impl 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 485serde_plain::derive_deserialize_from_fromstr!(Totp, "valid TOTP url");
8cbf9cb7
WB
486
487impl 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)]
504pub struct TotpValue {
505 value: u32,
506 digits: u32,
507}
508
509impl 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
526impl 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
537impl 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.
545impl 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]
563fn 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}