Passed
Push — master ( 25a926...39145a )
by
unknown
20:30
created

Totp::getTotpAuthUrl()   A

Complexity

Conditions 6
Paths 16

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 31
rs 9.2222
cc 6
nc 16
nop 3
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
18
namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
19
20
use Base32\Base32;
21
use TYPO3\CMS\Core\Context\Context;
22
use TYPO3\CMS\Core\Utility\GeneralUtility;
23
24
/**
25
 * Time-based one-time password (TOTP) implementation according to rfc6238
26
 *
27
 * @internal should only be used by the TYPO3 Core
28
 */
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
69
    /**
70
     * Generate a time-based one-time password for the given counter according to rfc4226
71
     *
72
     * @param int $counter A timestamp (counter) according to rfc6238
73
     * @return string The generated TOTP
74
     */
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
99
    /**
100
     * Verify the given time-based one-time password
101
     *
102
     * @param string $totp The time-based one-time password to be verified
103
     * @param int|null $gracePeriod The grace period for the TOTP +- (mainly to circumvent transmission delays)
104
     * @return bool
105
     */
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
129
    /**
130
     * Generate and return the otpauth URL for TOTP
131
     *
132
     * @param string $issuer
133
     * @param string $account
134
     * @param array $additionalParameters
135
     * @return string
136
     */
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
171
    /**
172
     * Compare given time-based one-time password with a time-based one-time
173
     * password generated from the known $counter (the moving factor).
174
     *
175
     * @param string $totp The time-based one-time password to verify
176
     * @param int $counter The counter value, the moving factor
177
     * @return bool
178
     */
179
    protected function compare(string $totp, int $counter): bool
180
    {
181
        return hash_equals($this->generateTotp($counter), $totp);
182
    }
183
184
    /**
185
     * Generate the counter value (moving factor) from the given timestamp
186
     *
187
     * @param int $timestamp
188
     * @return int
189
     */
190
    protected function getTimeCounter(int $timestamp): int
191
    {
192
        return (int)floor(($timestamp - $this->epoch) / $this->step);
193
    }
194
195
    /**
196
     * Generate the shared secret (K) by using a random and applying
197
     * additional authentication factors like username or email address.
198
     *
199
     * @param array $additionalAuthFactors
200
     * @return string
201
     */
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 === '' || strpos($secret, '=') !== false) {
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
}
221