blob: 7bc067d1eae6ef2702cb69547239802f0e8c1061 [file] [log] [blame]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01001<?php
2namespace RobThree\Auth;
3
4use RobThree\Auth\Providers\Qr\IQRCodeProvider;
5use RobThree\Auth\Providers\Rng\IRNGProvider;
6use RobThree\Auth\Providers\Time\ITimeProvider;
7
8// Based on / inspired by: https://github.com/PHPGangsta/GoogleAuthenticator
9// Algorithms, digits, period etc. explained: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
10class TwoFactorAuth
11{
12 private $algorithm;
13 private $period;
14 private $digits;
15 private $issuer;
16 private $qrcodeprovider = null;
17 private $rngprovider = null;
18 private $timeprovider = null;
19 private static $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=';
20 private static $_base32;
21 private static $_base32lookup = array();
22 private static $_supportedalgos = array('sha1', 'sha256', 'sha512', 'md5');
23
24 function __construct($issuer = null, $digits = 6, $period = 30, $algorithm = 'sha1', IQRCodeProvider $qrcodeprovider = null, IRNGProvider $rngprovider = null, ITimeProvider $timeprovider = null)
25 {
26 $this->issuer = $issuer;
27 if (!is_int($digits) || $digits <= 0)
28 throw new TwoFactorAuthException('Digits must be int > 0');
29 $this->digits = $digits;
30
31 if (!is_int($period) || $period <= 0)
32 throw new TwoFactorAuthException('Period must be int > 0');
33 $this->period = $period;
34
35 $algorithm = strtolower(trim($algorithm));
36 if (!in_array($algorithm, self::$_supportedalgos))
37 throw new TwoFactorAuthException('Unsupported algorithm: ' . $algorithm);
38 $this->algorithm = $algorithm;
39 $this->qrcodeprovider = $qrcodeprovider;
40 $this->rngprovider = $rngprovider;
41 $this->timeprovider = $timeprovider;
42
43 self::$_base32 = str_split(self::$_base32dict);
44 self::$_base32lookup = array_flip(self::$_base32);
45 }
46
47 /**
48 * Create a new secret
49 */
50 public function createSecret($bits = 80, $requirecryptosecure = true)
51 {
52 $secret = '';
53 $bytes = ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32)
54 $rngprovider = $this->getRngprovider();
55 if ($requirecryptosecure && !$rngprovider->isCryptographicallySecure())
56 throw new TwoFactorAuthException('RNG provider is not cryptographically secure');
57 $rnd = $rngprovider->getRandomBytes($bytes);
58 for ($i = 0; $i < $bytes; $i++)
59 $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values
60 return $secret;
61 }
62
63 /**
64 * Calculate the code with given secret and point in time
65 */
66 public function getCode($secret, $time = null)
67 {
68 $secretkey = $this->base32Decode($secret);
69
70 $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string
71 $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key
72 $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result
73 $value = unpack('N', $hashpart); // Unpack binary value
74 $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits
75
76 return str_pad($value % pow(10, $this->digits), $this->digits, '0', STR_PAD_LEFT);
77 }
78
79 /**
80 * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now
81 */
82 public function verifyCode($secret, $code, $discrepancy = 1, $time = null, &$timeslice = 0)
83 {
84 $timetamp = $this->getTime($time);
85
86 $timeslice = 0;
87
88 // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have
89 // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice
90 // of the match. Each iteration we either set the timeslice variable to the timeslice of the match
91 // or set the value to itself. This is an effort to maintain constant execution time for the code.
92 for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
93 $ts = $timetamp + ($i * $this->period);
94 $slice = $this->getTimeSlice($ts);
95 $timeslice = $this->codeEquals($this->getCode($secret, $ts), $code) ? $slice : $timeslice;
96 }
97
98 return $timeslice > 0;
99 }
100
101 /**
102 * Timing-attack safe comparison of 2 codes (see http://blog.ircmaxell.com/2014/11/its-all-about-time.html)
103 */
104 private function codeEquals($safe, $user) {
105 if (function_exists('hash_equals')) {
106 return hash_equals($safe, $user);
107 }
108 // In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that
109 // we don't leak information about the difference of the two strings.
110 if (strlen($safe)===strlen($user)) {
111 $result = 0;
112 for ($i = 0; $i < strlen($safe); $i++)
113 $result |= (ord($safe[$i]) ^ ord($user[$i]));
114 return $result === 0;
115 }
116 return false;
117 }
118
119 /**
120 * Get data-uri of QRCode
121 */
122 public function getQRCodeImageAsDataUri($label, $secret, $size = 200)
123 {
124 if (!is_int($size) || $size <= 0)
125 throw new TwoFactorAuthException('Size must be int > 0');
126
127 $qrcodeprovider = $this->getQrCodeProvider();
128 return 'data:'
129 . $qrcodeprovider->getMimeType()
130 . ';base64,'
131 . base64_encode($qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size));
132 }
133
134 /**
135 * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency)
136 */
137 public function ensureCorrectTime(array $timeproviders = null, $leniency = 5)
138 {
139 if ($timeproviders != null && !is_array($timeproviders))
140 throw new TwoFactorAuthException('No timeproviders specified');
141
142 if ($timeproviders == null)
143 $timeproviders = array(
144 new Providers\Time\NTPTimeProvider(),
145 new Providers\Time\HttpTimeProvider()
146 );
147
148 // Get default time provider
149 $timeprovider = $this->getTimeProvider();
150
151 // Iterate specified time providers
152 foreach ($timeproviders as $t) {
153 if (!($t instanceof ITimeProvider))
154 throw new TwoFactorAuthException('Object does not implement ITimeProvider');
155
156 // Get time from default time provider and compare to specific time provider and throw if time difference is more than specified number of seconds leniency
157 if (abs($timeprovider->getTime() - $t->getTime()) > $leniency)
158 throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t)));
159 }
160 }
161
162 private function getTime($time)
163 {
164 return ($time === null) ? $this->getTimeProvider()->getTime() : $time;
165 }
166
167 private function getTimeSlice($time = null, $offset = 0)
168 {
169 return (int)floor($time / $this->period) + ($offset * $this->period);
170 }
171
172 /**
173 * Builds a string to be encoded in a QR code
174 */
175 public function getQRText($label, $secret)
176 {
177 return 'otpauth://totp/' . rawurlencode($label)
178 . '?secret=' . rawurlencode($secret)
179 . '&issuer=' . rawurlencode($this->issuer)
180 . '&period=' . intval($this->period)
181 . '&algorithm=' . rawurlencode(strtoupper($this->algorithm))
182 . '&digits=' . intval($this->digits);
183 }
184
185 private function base32Decode($value)
186 {
187 if (strlen($value)==0) return '';
188
189 if (preg_match('/[^'.preg_quote(self::$_base32dict).']/', $value) !== 0)
190 throw new TwoFactorAuthException('Invalid base32 string');
191
192 $buffer = '';
193 foreach (str_split($value) as $char)
194 {
195 if ($char !== '=')
196 $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, 0, STR_PAD_LEFT);
197 }
198 $length = strlen($buffer);
199 $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' '));
200
201 $output = '';
202 foreach (explode(' ', $blocks) as $block)
203 $output .= chr(bindec(str_pad($block, 8, 0, STR_PAD_RIGHT)));
204 return $output;
205 }
206
207 /**
208 * @return IQRCodeProvider
209 * @throws TwoFactorAuthException
210 */
211 public function getQrCodeProvider()
212 {
213 // Set default QR Code provider if none was specified
214 if (null === $this->qrcodeprovider) {
215 return $this->qrcodeprovider = new Providers\Qr\QRServerProvider();
216 }
217 return $this->qrcodeprovider;
218 }
219
220 /**
221 * @return IRNGProvider
222 * @throws TwoFactorAuthException
223 */
224 public function getRngprovider()
225 {
226 if (null !== $this->rngprovider) {
227 return $this->rngprovider;
228 }
229 if (function_exists('random_bytes')) {
230 return $this->rngprovider = new Providers\Rng\CSRNGProvider();
231 }
232 if (function_exists('mcrypt_create_iv')) {
233 return $this->rngprovider = new Providers\Rng\MCryptRNGProvider();
234 }
235 if (function_exists('openssl_random_pseudo_bytes')) {
236 return $this->rngprovider = new Providers\Rng\OpenSSLRNGProvider();
237 }
238 if (function_exists('hash')) {
239 return $this->rngprovider = new Providers\Rng\HashRNGProvider();
240 }
241 throw new TwoFactorAuthException('Unable to find a suited RNGProvider');
242 }
243
244 /**
245 * @return ITimeProvider
246 * @throws TwoFactorAuthException
247 */
248 public function getTimeProvider()
249 {
250 // Set default time provider if none was specified
251 if (null === $this->timeprovider) {
252 return $this->timeprovider = new Providers\Time\LocalMachineTimeProvider();
253 }
254 return $this->timeprovider;
255 }
256}