$secret, 'issuer' => $issuer, 'algorithm' => 'SHA1', 'digits' => self::DIGITS, 'period' => self::TIME_STEP, ] ); $label = rawurlencode( $issuer ) . ':' . rawurlencode( $account ); return 'otpauth://totp/' . $label . '?' . $params; } /** * Generate a list of recovery codes (single-use 10-digit blocks). * * @return string */ public static function generate_recovery_codes() { $codes = []; for ( $i = 0; $i < self::RECOVERY_COUNT; $i++ ) { // 5-5 grouping for legibility, e.g. "12345-67890". $left = str_pad( (string) random_int( 0, 99999 ), 5, '0', STR_PAD_LEFT ); $right = str_pad( (string) random_int( 0, 99999 ), 5, '0', STR_PAD_LEFT ); $codes[] = $left . '-' . $right; } return $codes; } /** * Hash a recovery code for storage. PHP password_hash is overkill; we * use SHA-256(salt + code) since codes are high-entropy and used once. */ public static function hash_recovery_code( $code, $salt ) { return hash( 'sha256', $salt . trim( $code ) ); } // ─────────── Internals ──────────────────────────────── private static function generate_code( $secret, $counter ) { $key = self::base32_decode( $secret ); $bin = pack( 'N*', 0 ) . pack( 'N*', $counter ); $hash = hash_hmac( 'sha1', $bin, $key, true ); $offset = ord( $hash[19] ) & 0x0f; $bin = ( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 ) | ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) | ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) | ( ord( $hash[ $offset + 3 ] ) & 0xff ); $code = $bin % ( 10 ** self::DIGITS ); return str_pad( (string) $code, self::DIGITS, '0', STR_PAD_LEFT ); } public static function base32_encode( $bytes ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $out = ''; $bits = ''; for ( $i = 0; $i < strlen( $bytes ); $i++ ) { $bits .= str_pad( decbin( ord( $bytes[ $i ] ) ), 8, '0', STR_PAD_LEFT ); } $bits = str_pad( $bits, ceil( strlen( $bits ) / 5 ) * 5, '0', STR_PAD_RIGHT ); for ( $i = 0; $i < strlen( $bits ); $i += 5 ) { $out .= $alphabet[ bindec( substr( $bits, $i, 5 ) ) ]; } return $out; } public static function base32_decode( $text ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $text = strtoupper( preg_replace( '/[\s=]+/', '', $text ) ); $bits = ''; for ( $i = 0; $i < strlen( $text ); $i++ ) { $pos = strpos( $alphabet, $text[ $i ] ); if ( $pos === false ) { return ''; } $bits .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT ); } $out = ''; for ( $i = 0; $i + 8 <= strlen( $bits ); $i += 8 ) { $out .= chr( bindec( substr( $bits, $i, 8 ) ) ); } return $out; } }