issuer = $issuer; if (!is_int($digits) || $digits <= 0) { throw new TwoFactorAuthException("Digits must be int > 0"); } $this->digits = $digits; if (!is_int($period) || $period <= 0) { throw new TwoFactorAuthException("Period must be int > 0"); } $this->period = $period; $algorithm = strtolower(trim($algorithm)); if (!in_array($algorithm, self::$_supportedalgos)) { throw new TwoFactorAuthException("Unsupported algorithm: " . $algorithm); } $this->algorithm = $algorithm; $this->qrcodeprovider = $qrcodeprovider; $this->rngprovider = $rngprovider; $this->timeprovider = $timeprovider; self::$_base32 = str_split(self::$_base32dict); self::$_base32lookup = array_flip(self::$_base32); } /** * Create a new secret * * @param int $bits * @param bool $requirecryptosecure * * @return string */ public function createSecret($bits = 80, $requirecryptosecure = true) { $secret = ""; $bytes = (int) ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32) $rngprovider = $this->getRngProvider(); if ($requirecryptosecure && !$rngprovider->isCryptographicallySecure()) { throw new TwoFactorAuthException("RNG provider is not cryptographically secure"); } $rnd = $rngprovider->getRandomBytes($bytes); for ($i = 0; $i < $bytes; $i++) { $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values } return $secret; } /** * Calculate the code with given secret and point in time * * @param string $secret * @param ?int $time * * @return string */ public function getCode($secret, $time = null) { $secretkey = $this->base32Decode($secret); $timestamp = "\0\0\0\0" . pack("N*", $this->getTimeSlice($this->getTime($time))); // Pack time into binary string $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0f, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result $value = unpack("N", $hashpart); // Unpack binary value $value = $value[1] & 0x7fffffff; // Drop MSB, keep only 31 bits return str_pad( (string) ($value % pow(10, $this->digits)), $this->digits, "0", STR_PAD_LEFT, ); } /** * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now * * @param string $secret * @param string $code * @param int $discrepancy * @param ?int $time * @param int $timeslice * * @return bool */ public function verifyCode($secret, $code, $discrepancy = 1, $time = null, &$timeslice = 0) { $timestamp = $this->getTime($time); $timeslice = 0; // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice // of the match. Each iteration we either set the timeslice variable to the timeslice of the match // or set the value to itself. This is an effort to maintain constant execution time for the code. for ($i = -$discrepancy; $i <= $discrepancy; $i++) { $ts = $timestamp + $i * $this->period; $slice = $this->getTimeSlice($ts); $timeslice = $this->codeEquals($this->getCode($secret, $ts), $code) ? $slice : $timeslice; } return $timeslice > 0; } /** * Timing-attack safe comparison of 2 codes (see http://blog.ircmaxell.com/2014/11/its-all-about-time.html) * * @param string $safe * @param string $user * * @return bool */ private function codeEquals($safe, $user) { if (function_exists("hash_equals")) { return hash_equals($safe, $user); } // In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that // we don't leak information about the difference of the two strings. if (strlen($safe) === strlen($user)) { $result = 0; for ($i = 0; $i < strlen($safe); $i++) { $result |= ord($safe[$i]) ^ ord($user[$i]); } return $result === 0; } return false; } /** * Get data-uri of QRCode * * @param string $label * @param string $secret * @param mixed $size * * @return string */ public function getQRCodeImageAsDataUri($label, $secret, $size = 200) { if (!is_int($size) || $size <= 0) { throw new TwoFactorAuthException("Size must be int > 0"); } $qrcodeprovider = $this->getQrCodeProvider(); return "data:" . $qrcodeprovider->getMimeType() . ";base64," . base64_encode( $qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size), ); } /** * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency) * @param ?array $timeproviders * @param int $leniency * * @return void */ public function ensureCorrectTime(array $timeproviders = null, $leniency = 5) { if ($timeproviders === null) { $timeproviders = [new NTPTimeProvider(), new HttpTimeProvider()]; } // Get default time provider $timeprovider = $this->getTimeProvider(); // Iterate specified time providers foreach ($timeproviders as $t) { if (!($t instanceof ITimeProvider)) { throw new TwoFactorAuthException("Object does not implement ITimeProvider"); } // 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 if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) { throw new TwoFactorAuthException( sprintf( "Time for timeprovider is off by more than %d seconds when compared to %s", $leniency, get_class($t), ), ); } } } /** * @param ?int $time * * @return int */ private function getTime($time = null) { return $time === null ? $this->getTimeProvider()->getTime() : $time; } /** * @param int $time * @param int $offset * * @return int */ private function getTimeSlice($time = null, $offset = 0) { return (int) floor($time / $this->period) + $offset * $this->period; } /** * Builds a string to be encoded in a QR code * * @param string $label * @param string $secret * * @return string */ public function getQRText($label, $secret) { return "otpauth://totp/" . rawurlencode($label) . "?secret=" . rawurlencode($secret) . "&issuer=" . rawurlencode((string) $this->issuer) . "&period=" . intval($this->period) . "&algorithm=" . rawurlencode(strtoupper($this->algorithm)) . "&digits=" . intval($this->digits); } /** * @param string $value * @return string */ private function base32Decode($value) { if (strlen($value) == 0) { return ""; } if (preg_match("/[^" . preg_quote(self::$_base32dict) . "]/", $value) !== 0) { throw new TwoFactorAuthException("Invalid base32 string"); } $buffer = ""; foreach (str_split($value) as $char) { if ($char !== "=") { $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, "0", STR_PAD_LEFT); } } $length = strlen($buffer); $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, " ")); $output = ""; foreach (explode(" ", $blocks) as $block) { $output .= chr(bindec(str_pad($block, 8, "0", STR_PAD_RIGHT))); } return $output; } /** * @return IQRCodeProvider * @throws TwoFactorAuthException */ public function getQrCodeProvider() { // Set default QR Code provider if none was specified if (null === $this->qrcodeprovider) { return $this->qrcodeprovider = new QRServerProvider(); } return $this->qrcodeprovider; } /** * @return IRNGProvider * @throws TwoFactorAuthException */ public function getRngProvider() { if (null !== $this->rngprovider) { return $this->rngprovider; } if (function_exists("random_bytes")) { return $this->rngprovider = new CSRNGProvider(); } if (function_exists("mcrypt_create_iv")) { return $this->rngprovider = new MCryptRNGProvider(); } if (function_exists("openssl_random_pseudo_bytes")) { return $this->rngprovider = new OpenSSLRNGProvider(); } if (function_exists("hash")) { return $this->rngprovider = new HashRNGProvider(); } throw new TwoFactorAuthException("Unable to find a suited RNGProvider"); } /** * @return ITimeProvider * @throws TwoFactorAuthException */ public function getTimeProvider() { // Set default time provider if none was specified if (null === $this->timeprovider) { return $this->timeprovider = new LocalMachineTimeProvider(); } return $this->timeprovider; } }