= 8 ) { $res .= chr( ( $buf >> ( $buf_len - 8 ) ) & 0xFF ); $buf_len -= 8; } } return $res; } /** * Generate secret * * @param int $length Length of the secret. * * @return string Generated secret. */ public function generate_secret( $length = 32 ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $secret = ''; $random_bytes = random_bytes( $length ); for ( $i = 0; $i < $length; $i++ ) { $secret .= $alphabet[ ord( $random_bytes[ $i ] ) & 31 ]; } return $secret; } /** * Build an otpauth:// provisioning URI for authenticator apps. * * @param string $secret Base32 secret key. * @param string $label Account label shown in the authenticator app. * @param string $issuer Issuer name shown in the authenticator app. * @param string $algo Hash algorithm. * @param int $digits Number of digits in the generated code. * @param int $period Time step, in seconds. * * @return string otpauth:// URI. */ public function generate_uri( $secret, $label, $issuer, $algo = 'sha1', $digits = 6, $period = 30 ) { return sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d', rawurlencode( $issuer ), rawurlencode( $label ), $secret, rawurlencode( $issuer ), strtoupper( $algo ), $digits, $period ); } /** * Verify a TOTP code. * * Replay protection is opt-in via two callables, both given the time step the code matched: * $is_used_fn( string $code, int $step ) : bool — return true if the code/step was already consumed. * $mark_used_fn( string $code, int $expires_at, int $step ) : void — persist the code/step as used. * * Example (last accepted step in user meta; any step <= it is rejected): * $totp->verify_code( * $secret, $input, * 30, 6, 1, 'sha1', * fn( $c, $step ) => $step <= (int) get_user_meta( $user_id, 'totp_last_step', true ), * fn( $c, $exp, $step ) => update_user_meta( $user_id, 'totp_last_step', $step ) * ); * * @param string $secret Secret key. * @param string $code Code to verify. * @param int $time_step Time step. * @param int $digits Number of digits. * @param int $window Window size. * @param string $algo Algorithm. * @param callable $is_used_fn Function to check if code was used. * @param callable $mark_used_fn Function to mark code as used. * * @return bool True if code is valid, false otherwise. */ public function verify_code( $secret, $code, $time_step = 30, $digits = 6, $window = 1, $algo = 'sha1', ?callable $is_used_fn = null, ?callable $mark_used_fn = null ) { $code = str_pad( (string) $code, $digits, '0', STR_PAD_LEFT ); $current_slot = floor( time() / $time_step ); for ( $i = -$window; $i <= $window; $i++ ) { $expected = $this->get_totp_code( $secret, $time_step, $digits, $current_slot + $i, $algo ); if ( hash_equals( $expected, $code ) ) { // Replay check against the time step this code belongs to. if ( null !== $is_used_fn && $is_used_fn( $code, (int) ( $current_slot + $i ) ) ) { return false; } if ( null !== $mark_used_fn ) { // Expire the record at the end of the last valid window slot. $expires_at = (int) ( ( $current_slot + $window + 1 ) * $time_step ); $mark_used_fn( $code, $expires_at, (int) ( $current_slot + $i ) ); } return true; } } return false; } /** * Get TOTP code * * @param string $secret Secret key. * @param int $time_step Time step. * @param int $digits Number of digits. * @param int $timestamp Timestamp. * @param string $algo Algorithm. * * @return string TOTP code. */ public function get_totp_code( $secret, $time_step = 30, $digits = 6, $timestamp = null, $algo = 'sha1' ) { // 1. Decode secret from Base32 to Binary $binary_secret = $this->base32_decode( $secret ); // 2. Calculate the "Counter" (current time / 30 seconds) $timestamp = $timestamp ?? floor( time() / $time_step ); // 3. Pack counter into 8-byte binary string (Big-endian) $binary_time = pack( 'N*', 0 ) . pack( 'N*', $timestamp ); // 4. Generate HMAC using the specified algorithm $hash = hash_hmac( $algo, $binary_time, $binary_secret, true ); // 5. Dynamic Truncation — offset derived from last byte of hash $offset = ord( $hash[ strlen( $hash ) - 1 ] ) & 0xf; $truncated_hash = ( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 | ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 | ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 | ( ord( $hash[ $offset + 3 ] ) & 0xff ) ); // 6. Extract the digits $code = $truncated_hash % pow( 10, $digits ); return str_pad( $code, $digits, '0', STR_PAD_LEFT ); } }