period); for ($i = -$discrepancy; $i <= $discrepancy; $i++) { $calculatedCode = $this->getCode($secret, (int)($currentTimeSlice + $i)); if ($this->timingSafeEquals($calculatedCode, $code)) { return true; } } return false; } /** * Generate TOTP code for a given time slice * @param string $secret The secret key * @param int|null $timeSlice The time slice (defaults to current) * @return string The generated code */ public function getCode(string $secret, ?int $timeSlice = null): string { if ($timeSlice === null) { $timeSlice = (int)floor(time() / $this->period); } $secretKey = $this->base32Decode($secret); // Pack time into binary string $time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice); // Hash with secret $hash = hash_hmac('sha1', $time, $secretKey, true); // Use last 4 bits as offset $offset = ord(substr($hash, -1)) & 0x0F; // Get 4 bytes from offset $hashPart = substr($hash, $offset, 4); // Unpack binary value $value = unpack('N', $hashPart); $value = $value[1]; // Only 32 bits $value = $value & 0x7FFFFFFF; // Get the required digits $modulo = pow(10, $this->codeLength); return str_pad($value % $modulo, $this->codeLength, '0', STR_PAD_LEFT); } /** * Decode Base32 string * @param string $secret Base32 encoded string * @return string Binary string */ protected function base32Decode(string $secret): string { if (empty($secret)) { return ''; } $base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $base32charsFlipped = array_flip(str_split($base32chars)); $paddingCharCount = substr_count($secret, '='); $allowedValues = [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('=', $allowedValues[$i]) ) { return ''; } } $secret = str_replace('=', '', $secret); $secret = str_split($secret); $binaryString = ''; for ($i = 0; $i < count($secret); $i = $i + 8) { $x = ''; if (!isset($base32charsFlipped[$secret[$i]])) { return ''; } for ($j = 0; $j < 8; $j++) { $char = $secret[$i + $j] ?? ''; $charValue = $base32charsFlipped[$char] ?? 0; $x .= str_pad(base_convert((string)$charValue, 10, 2), 5, '0', STR_PAD_LEFT); } $eightBits = str_split($x, 8); for ($z = 0; $z < count($eightBits); $z++) { $y = chr((int)base_convert($eightBits[$z], 2, 10)); $binaryString .= ($y || ord($y) == 48) ? $y : ''; } } return $binaryString; } /** * Timing-safe string comparison * @param string $safeString Known string * @param string $userString User provided string * @return bool True if strings match */ protected function timingSafeEquals(string $safeString, string $userString): bool { return hash_equals($safeString, $userString); } }