0,'B'=>1,'C'=>2,'D'=>3,'E'=>4,'F'=>5,'G'=>6,'H'=>7, 'I'=>8,'J'=>9,'K'=>10,'L'=>11,'M'=>12,'N'=>13,'O'=>14,'P'=>15, 'Q'=>16,'R'=>17,'S'=>18,'T'=>19,'U'=>20,'V'=>21,'W'=>22,'X'=>23, 'Y'=>24,'Z'=>25,'2'=>26,'3'=>27,'4'=>28,'5'=>29,'6'=>30,'7'=>31 ]; /** * Generate 16-character Base32 secret key */ public static function generateSecret(): string { $chars = array_keys(self::$base32Chars); $secret = ''; for ($i = 0; $i < 16; $i++) { $secret .= $chars[random_int(0, 31)]; } return $secret; } /** * Generate otpauth:// URI for Google Authenticator QR Code */ public static function getQrCodeUrl(string $issuer, string $accountName, string $secret): string { $encodedIssuer = urlencode($issuer); $encodedAccount = urlencode($accountName); return "otpauth://totp/{$encodedIssuer}:{$encodedAccount}?secret={$secret}&issuer={$encodedIssuer}&algorithm=SHA1&digits=6&period=30"; } /** * Verify 6-digit TOTP Code against Secret (RFC 6238) */ public static function verifyCode(string $secret, string $code, int $window = 1): bool { if (strlen($code) !== 6 || !is_numeric($code)) return false; $timestamp = time(); $timeSlice = (int)floor($timestamp / 30); for ($i = -$window; $i <= $window; $i++) { $calculated = self::calculateCode($secret, $timeSlice + $i); if (hash_equals($calculated, str_pad($code, 6, '0', STR_PAD_LEFT))) { return true; } } return false; } /** * Calculate 6-digit TOTP for a specific time slice */ private static function calculateCode(string $secret, int $timeSlice): string { $secretKey = self::base32Decode(strtoupper($secret)); // Pack time slice into 64-bit big-endian binary string $timeBytes = pack('N2', 0, $timeSlice); $hash = hash_hmac('sha1', $timeBytes, $secretKey, true); $offset = ord($hash[19]) & 0xf; $otp = ( ((ord($hash[$offset+0]) & 0x7f) << 24 ) | ((ord($hash[$offset+1]) & 0xff) << 16 ) | ((ord($hash[$offset+2]) & 0xff) << 8 ) | (ord($hash[$offset+3]) & 0xff) ) % 1000000; return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); } private static function base32Decode(string $secret): string { $secret = strtoupper($secret); $buffer = 0; $bitsLeft = 0; $result = ''; for ($i = 0; $i < strlen($secret); $i++) { $char = $secret[$i]; if (!isset(self::$base32Chars[$char])) continue; $buffer <<= 5; $buffer |= self::$base32Chars[$char]; $bitsLeft += 5; if ($bitsLeft >= 8) { $bitsLeft -= 8; $result .= chr(($buffer >> $bitsLeft) & 0xFF); } } return $result; } }