codeLength = $codeLength ?? 6; $this->alg = $usedAlg ?? 'SHA256'; if($this->codeLength < 6) { throw new ValueError("Code is less then 6"); } if(!in_array(strtolower($this->alg), hash_hmac_algos())) { throw new ValueError("Hash function is not supported by hash_hmac"); } } /** * Gets the used algorithm * @return int */ public function getCodeLength(): int { return $this->codeLength; } /** * Gets the set code length * @return string */ public function getAlgorithm(): string { return $this->alg; } /** * Calculate the code, with given secret and point in time. * * @param string $secret * @param float|null $timeSlice * * @return string */ public function getCode(string $secret, ?float $timeSlice = null): string { $timeSlice = $timeSlice ?? floor(time() / 30); $secretKey = self::base32Decode($secret); // Pack time into binary string $time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice); // Hash it with users secret key $hm = hash_hmac($this->alg, $time, $secretKey, true); // Use last nipple of result as index/offset $offset = ord(substr($hm, -1)) & 0x0F; // grab 4 bytes of the result $hashPart = substr($hm, $offset, 4); // Unpack binary value $value = unpack('N', $hashPart); $value = $value[1]; // Only 32 bits $value = $value & 0x7FFFFFFF; $modulo = pow(10, $this->codeLength); return str_pad((string)($value % $modulo), $this->codeLength, '0', STR_PAD_LEFT); } /** * Get QR-Code URL for image, from Google charts. * * @param string $label * @param string $secret * @param string|null $issuer * @param array $params width, height and ecc * * @return string *@example getQRCodeGoogleUrl('Example code', '123456789') * */ public function getQRCodeGoogleUrl(string $secret, string $label, ?string $issuer = null, array $params = []): string { $params += [ 'width' => 200, 'height' => 200, 'ecc' => 'M', ]; $width = !empty($params['width']) && (int)$params['width'] > 0 ? (int)$params['width'] : 200; $height = !empty($params['height']) && (int)$params['height'] > 0 ? (int)$params['height'] : 200; $ecc = !empty($params['ecc']) && in_array($params['ecc'], ['L', 'M', 'Q', 'H']) ? $params['ecc'] : 'M'; $urlencoded = urlencode('otpauth://totp/' . (!is_null($issuer) ? $issuer . ':' : '') . $label . '?secret=' . $secret . ($this->alg != 'SHA1' ? '&algorithm='.$this->alg : '') . (!is_null($issuer) ? '&issuer=' . $issuer : '')); return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size={$width}x$height&ecc=$ecc"; } /** * Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now. * * @param string $secret * @param string $code * @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after) * @param int|null $currentTimeSlice time slice if we want to use other that time() * * @return bool */ public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int $currentTimeSlice = null): bool { $currentTimeSlice = $currentTimeSlice ?? floor(time() / 30); if (strlen($code) != $this->codeLength) { return false; } for ($i = -$discrepancy; $i <= $discrepancy; ++$i) { $calculatedCode = $this->getCode($secret, $currentTimeSlice + $i); if (self::timingSafeEquals($calculatedCode, $code)) { return true; } } return false; } /** * Helper class to decode base32. * * @param $secret * * @return string */ protected function base32Decode($secret): string { if (empty($secret)) { return ''; } $base32chars = $this->getBase32LookupTable(); $base32charsFlipped = array_flip($base32chars); $paddingCharCount = substr_count($secret, $base32chars[32]); $allowedValues = array(6, 4, 3, 1, 0); if (!in_array($paddingCharCount, $allowedValues)) { return ''; } for ($i = 0; $i < 4; ++$i) { if ($paddingCharCount == $allowedValues[$i] && substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) { return ''; } } $secret = str_replace('=', '', $secret); $secret = str_split($secret); $binaryString = ''; for ($i = 0; $i < count($secret); $i = $i + 8) { $x = ''; if (!in_array($secret[$i], $base32chars)) { return ''; } for ($j = 0; $j < 8; ++$j) { $x .= str_pad(base_convert((string)@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT); } $eightBits = str_split($x, 8); for ($z = 0; $z < count($eightBits); ++$z) { $binaryString .= (($y = chr((int)base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : ''; } } return $binaryString; } /** * Create new secret. * 16 characters, randomly chosen from the allowed base32 characters. * * @param int $secretLength * * @return string * @throws Exception */ public static function createSecret(int $secretLength = 32): string { $validChars = self::getBase32LookupTable(); // Valid secret lengths are 80 to 640 bits if ($secretLength < 16) { throw new ValueError('The secret is too short'); } // Valid secret lengths are 80 to 640 bits if ($secretLength > 128) { throw new ValueError('The secret is too long'); } $secret = ''; $rnd = ''; if (function_exists('random_bytes')) { $rnd = random_bytes($secretLength); } elseif (function_exists('openssl_random_pseudo_bytes')) { $rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong); if (!$cryptoStrong) { $rnd = ''; } } if (!empty($rnd)) { for ($i = 0; $i < $secretLength; ++$i) { $secret .= $validChars[ord($rnd[$i]) & 31]; } } else { throw new Exception('No source of secure random'); } return $secret; } /** * Get array with all 32 characters for decoding from/encoding to base32. * * @return string[] */ public static function getBase32LookupTable(): array { return [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 '=', // padding char ]; } /** * A timing safe equals comparison * more info here: http://blog.ircmaxell.com/2014/11/its-all-about-time.html. * * @param string $safeString The internal (safe) value to be checked * @param string $userString The user submitted (unsafe) value * * @return bool True if the two strings are identical */ public static function timingSafeEquals(string $safeString, string $userString): bool { if (function_exists('hash_equals')) { return hash_equals($safeString, $userString); } $safeLen = strlen($safeString); $userLen = strlen($userString); if ($userLen != $safeLen) { return false; } $result = 0; for ($i = 0; $i < $userLen; ++$i) { $result |= (ord($safeString[$i]) ^ ord($userString[$i])); } // They are only identical strings if $result is exactly 0... return $result === 0; } }