‪TYPO3CMS  11.5
Totp.php
Go to the documentation of this file.
1 <?php
2 
3 /*
4  * This file is part of the TYPO3 CMS project.
5  *
6  * It is free software; you can redistribute it and/or modify it under
7  * the terms of the GNU General Public License, either version 2
8  * of the License, or any later version.
9  *
10  * For the full copyright and license information, please read the
11  * LICENSE.txt file that was distributed with this source code.
12  *
13  * The TYPO3 project - inspiring people to share!
14  */
15 
16 declare(strict_types=1);
17 
19 
20 use Base32\Base32;
23 
29 class ‪Totp
30 {
31  private const ‪ALLOWED_ALGOS = ['sha1', 'sha256', 'sha512'];
32  private const ‪MIN_LENGTH = 6;
33  private const ‪MAX_LENGTH = 8;
34 
35  protected string ‪$secret;
36  protected string ‪$algo;
37  protected int ‪$length;
38  protected int ‪$step;
39  protected int ‪$epoch;
40 
41  public function ‪__construct(
42  string ‪$secret,
43  string ‪$algo = 'sha1',
44  int ‪$length = 6,
45  int ‪$step = 30,
46  int ‪$epoch = 0
47  ) {
48  $this->secret = ‪$secret;
49  $this->step = ‪$step;
50  $this->epoch = ‪$epoch;
51 
52  if (!in_array(‪$algo, self::ALLOWED_ALGOS, true)) {
53  throw new \InvalidArgumentException(
54  ‪$algo . ' is not allowed. Allowed algos are: ' . implode(',', self::ALLOWED_ALGOS),
55  1611748791
56  );
57  }
58  $this->algo = ‪$algo;
59 
60  if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
61  throw new \InvalidArgumentException(
62  ‪$length . ' is not allowed as TOTP length. Must be between ' . self::MIN_LENGTH . ' and ' . self::MAX_LENGTH,
63  1611748792
64  );
65  }
66  $this->length = ‪$length;
67  }
68 
75  public function ‪generateTotp(int $counter): string
76  {
77  // Generate a 8-byte counter value (C) from the given counter input
78  $binary = [];
79  while ($counter !== 0) {
80  $binary[] = pack('C*', $counter);
81  $counter >>= 8;
82  }
83  // Implode and fill with NULL values
84  $binary = str_pad(implode(array_reverse($binary)), 8, "\000", STR_PAD_LEFT);
85  // Create a 20-byte hash string (HS) with given algo and decoded shared secret (K)
86  $hash = hash_hmac($this->algo, $binary, $this->‪getDecodedSecret());
87  // Convert hash into hex and generate an array with the decimal values of the hash
88  $hmac = [];
89  foreach (str_split($hash, 2) as $hex) {
90  $hmac[] = hexdec($hex);
91  }
92  // Generate a 4-byte string with dynamic truncation (DT)
93  $offset = $hmac[\count($hmac) - 1] & 0xf;
94  $bits = ((($hmac[$offset + 0] & 0x7f) << 24) | (($hmac[$offset + 1] & 0xff) << 16) | (($hmac[$offset + 2] & 0xff) << 8) | ($hmac[$offset + 3] & 0xff));
95  // Compute the TOTP value by reducing the bits modulo 10^Digits and filling it with zeros '0'
96  return str_pad((string)($bits % (10 ** $this->length)), $this->length, '0', STR_PAD_LEFT);
97  }
98 
106  public function ‪verifyTotp(string $totp, int $gracePeriod = null): bool
107  {
108  $counter = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
109 
110  // If no grace period is given, only check once
111  if ($gracePeriod === null) {
112  return $this->‪compare($totp, $this->‪getTimeCounter($counter));
113  }
114 
115  // Check the token within the given grace period till it can be verified or the grace period is exhausted
116  for ($i = 0; $i < $gracePeriod; ++$i) {
117  $next = $i * $this->step + $counter;
118  $prev = $counter - $i * ‪$this->step;
119  if ($this->‪compare($totp, $this->‪getTimeCounter($next))
120  || $this->‪compare($totp, $this->‪getTimeCounter($prev))
121  ) {
122  return true;
123  }
124  }
125 
126  return false;
127  }
128 
137  public function ‪getTotpAuthUrl(string $issuer, string $account = '', array $additionalParameters = []): string
138  {
139  $parameters = [
140  'secret' => ‪$this->secret,
141  'issuer' => htmlspecialchars($issuer),
142  ];
143 
144  // Common OTP applications expect the following parameters:
145  // - algo: sha1
146  // - period: 30 (in seconds)
147  // - digits 6
148  // - epoch: 0
149  // Only if we differ from these assumption, the exact values must be provided.
150  if ($this->algo !== 'sha1') {
151  $parameters['algorithm'] = ‪$this->algo;
152  }
153  if ($this->step !== 30) {
154  $parameters['period'] = ‪$this->step;
155  }
156  if ($this->length !== 6) {
157  $parameters['digits'] = ‪$this->length;
158  }
159  if ($this->epoch !== 0) {
160  $parameters['epoch'] = ‪$this->epoch;
161  }
162 
163  // Generate the otpauth URL by providing information like issuer and account
164  return sprintf(
165  'otpauth://totp/%s?%s',
166  rawurlencode($issuer . ($account !== '' ? ':' . $account : '')),
167  http_build_query(array_merge($parameters, $additionalParameters), '', '&', PHP_QUERY_RFC3986)
168  );
169  }
170 
179  protected function ‪compare(string $totp, int $counter): bool
180  {
181  return hash_equals($this->‪generateTotp($counter), $totp);
182  }
183 
190  protected function ‪getTimeCounter(int $timestamp): int
191  {
192  return (int)floor(($timestamp - $this->epoch) / $this->step);
193  }
194 
202  public static function ‪generateEncodedSecret(array $additionalAuthFactors = []): string
203  {
204  ‪$secret = '';
205  $payload = implode($additionalAuthFactors);
206  // Prevent secrets with a trailing pad character since this will eventually break the QR-code feature
207  while (‪$secret === '' || str_contains(‪$secret, '=')) {
208  // RFC 4226 (https://tools.ietf.org/html/rfc4226#section-4) suggests 160 bit TOTP secret keys
209  // HMAC-SHA1 based on static factors and a 160 bit HMAC-key lead again to 160 bits (20 bytes)
210  // base64-encoding (factor 1.6) 20 bytes lead to 32 uppercase characters
211  ‪$secret = Base32::encode(hash_hmac('sha1', $payload, random_bytes(20), true));
212  }
213  return ‪$secret;
214  }
215 
216  protected function ‪getDecodedSecret(): string
217  {
218  return Base32::decode($this->secret);
219  }
220 }
‪TYPO3\CMS\Core\Authentication\Mfa\Provider
Definition: RecoveryCodes.php:18
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\$length
‪int $length
Definition: Totp.php:37
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\getTotpAuthUrl
‪string getTotpAuthUrl(string $issuer, string $account='', array $additionalParameters=[])
Definition: Totp.php:137
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\__construct
‪__construct(string $secret, string $algo='sha1', int $length=6, int $step=30, int $epoch=0)
Definition: Totp.php:41
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\verifyTotp
‪bool verifyTotp(string $totp, int $gracePeriod=null)
Definition: Totp.php:106
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\getDecodedSecret
‪getDecodedSecret()
Definition: Totp.php:216
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\$epoch
‪int $epoch
Definition: Totp.php:39
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\$step
‪int $step
Definition: Totp.php:38
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\generateEncodedSecret
‪static string generateEncodedSecret(array $additionalAuthFactors=[])
Definition: Totp.php:202
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp
Definition: Totp.php:30
‪TYPO3\CMS\Core\Context\Context
Definition: Context.php:53
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\MAX_LENGTH
‪const MAX_LENGTH
Definition: Totp.php:33
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\$secret
‪string $secret
Definition: Totp.php:35
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\getTimeCounter
‪int getTimeCounter(int $timestamp)
Definition: Totp.php:190
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\$algo
‪string $algo
Definition: Totp.php:36
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\ALLOWED_ALGOS
‪const ALLOWED_ALGOS
Definition: Totp.php:31
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:50
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\compare
‪bool compare(string $totp, int $counter)
Definition: Totp.php:179
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\generateTotp
‪string generateTotp(int $counter)
Definition: Totp.php:75
‪TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp\MIN_LENGTH
‪const MIN_LENGTH
Definition: Totp.php:32