id; } /** * Generates the setup form HTML for TOTP two-factor authentication. * * Displays either a deactivation form if 2FA is already enabled, or a setup form * with QR code and manual secret for new users. The setup form allows users to * scan a QR code with their authenticator app and verify the setup with a code. * * @param WP_User $user The WordPress user object to generate the form for. * * @return string HTML content for the setup form * * @since 1.0.0 */ public function setup_form( WP_User $user ): string { return ''; } /** * Retrieves the setup data required for TOTP 2FA configuration. * * Generates a new TOTP secret and corresponding QR code image for the user * to scan with their authenticator app during the setup process. * * @param WP_User $user The WordPress user object to generate setup data for. * @param string $pending_secret Secret from a failed activation attempt. Reusing it keeps the * QR code the user already scanned. Ignored unless it is base32. * * @return array An associative array containing: * - 'secret': The TOTP secret key. * - 'qr_image': Data URI of the QR code image. * * @since 1.0.0 */ public function get_setup_data( WP_User $user, string $pending_secret = '' ): array { // Show setup form for new users. $secret = get_user_meta( $user->ID, 'brain2fa_secret', true ); if ( ! $secret && 1 === preg_match( '/^[A-Z2-7]{16,128}$/', $pending_secret ) ) { $secret = $pending_secret; } if ( ! $secret ) { if ( class_exists( '\OTPHP\\TOTP' ) ) { $t = \OTPHP\TOTP::generate( new SystemClock() ); $secret = $t->getSecret(); } else { $secret = $this->random_base32( 26 ); } } $issuer = get_bloginfo( 'name' ); if ( ! empty( $user->user_email ) ) { $account = $user->user_email; } else { $account = $user->user_login; } $uri = sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30', rawurlencode( $issuer ), rawurlencode( $account ), rawurlencode( $secret ), rawurlencode( $issuer ) ); // Generate QR code using bacon/bacon-qr-code. $qr_data_uri = $this->generate_qr_code( $uri ); return array( 'secret' => $secret, 'qr_image' => $qr_data_uri, ); } /** * Saves the TOTP 2FA setup configuration for a user. * * Handles both activation and deactivation of TOTP 2FA for a user account. * When activating, validates the provided secret and verification code before * storing the configuration. When deactivating, removes all stored 2FA data. * * @param WP_User $user The WordPress user object to configure 2FA for. * @param array $data Setup data containing: * - 'brain2fa_deactivate' (optional): If present, deactivates 2FA. * - 'brain2fa_secret' (string): The TOTP secret key for activation. * - 'brain2fa_code' (string): The verification code to validate setup. * * @return bool|array|WP_Error Returns true on successful deactivation, * array with 'success' and 'recovery_codes' keys on activation, * WP_Error on invalid code * * @since 1.0.0 */ public function save_setup( WP_User $user, array $data ) { if ( ! empty( $data['brain2fa_deactivate'] ) ) { delete_user_meta( $user->ID, 'brain2fa_secret' ); delete_user_meta( $user->ID, 'brain2fa_enabled' ); delete_user_meta( $user->ID, 'brain2fa_recovery_codes' ); delete_user_meta( $user->ID, self::LAST_STEP_META_KEY ); Utils::forget_remembered_devices( $user->ID ); return true; } $secret = sanitize_text_field( $data['secret'] ); $code = preg_replace( '/[^0-9]/', '', $data['code'] ); if ( $this->verify( $user, $code, array( 'secret' => $secret ) ) ) { update_user_meta( $user->ID, 'brain2fa_secret', $secret ); update_user_meta( $user->ID, 'brain2fa_enabled', 1 ); // Generate recovery codes on first setup and return them. $existing_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true ); if ( empty( $existing_codes ) ) { $recovery_codes = $this->generate_recovery_codes( $user ); return array( 'success' => true, 'recovery_codes' => $recovery_codes, ); } // Re-activation with existing recovery codes — still a success. return array( 'success' => true ); } return new \WP_Error( 'invalid', __( 'Invalid verification code.', 'brain-2fa' ) ); } /** * Sends a TOTP challenge to the user. * * Since TOTP (Time-based One-Time Password) is generated locally on the user's device, * there is no actual challenge to send. This method always returns true to indicate * that the challenge process is complete. * * @param WP_User $user The WordPress user object for whom the challenge is being sent. * @param array $context Optional context data that may be used by other authentication methods. * * @return bool Always returns true as TOTP doesn't require server-side challenge sending. */ public function send_challenge( WP_User $user, array $context = array() ): bool { // TOTP is generated on device; nothing to send. return true; } /** * Verifies a TOTP token for the given user. * * Validates a time-based one-time password token against the user's stored secret, * then falls back to the user's recovery codes if TOTP verification fails. * * @param WP_User $user The WordPress user object to verify the token for. * @param string $token The TOTP token to verify. * @param array $context Optional context data that may contain a 'secret' key. * * @return bool True if the token is valid, false otherwise. * * @since 1.0.0 */ public function verify( WP_User $user, string $token, array $context = array() ): bool { $secret = $context['secret'] ?? get_user_meta( $user->ID, 'brain2fa_secret', true ); if ( ! $secret ) { return false; } if ( $this->verify_totp_code( $user, (string) $secret, $token ) ) { return true; } return $this->verify_recovery_code( $user, $token ); } /** * Verifies a six-digit TOTP code with clock-drift tolerance and replay protection. * * Accepts codes for the current time step and DRIFT_STEPS steps on either side. * A code is accepted only for a time step later than the last one this user * successfully used, so an intercepted code cannot be replayed within its * validity window, and an older code cannot be used after a newer one. * * @param WP_User $user User whose last accepted time step is checked and updated. * @param string $secret The base32-encoded TOTP secret key. * @param string $token The submitted code. * * @return bool True if the code is valid and has not been used before. * * @since 1.0.2 */ protected function verify_totp_code( WP_User $user, string $secret, string $token ): bool { $token = (string) preg_replace( '/\s+/', '', $token ); if ( 1 !== preg_match( '/^\d{6}$/', $token ) ) { return false; } $current_step = intdiv( time(), self::PERIOD ); $last_step = absint( get_user_meta( $user->ID, self::LAST_STEP_META_KEY, true ) ); for ( $step = $current_step - self::DRIFT_STEPS; $step <= $current_step + self::DRIFT_STEPS; $step++ ) { if ( $step <= $last_step ) { continue; } if ( hash_equals( $this->code_for_step( $secret, $step ), $token ) ) { update_user_meta( $user->ID, self::LAST_STEP_META_KEY, $step ); return true; } } return false; } /** * Generates the TOTP code for a time step. * * Uses the OTPHP library when available, otherwise the built-in HOTP implementation. * * @param string $secret The base32-encoded TOTP secret key. * @param int $step Time step (Unix time divided by the period). * * @return string Six-digit code, or an empty string if the secret is invalid. * * @since 1.0.2 */ protected function code_for_step( string $secret, int $step ): string { try { if ( class_exists( '\OTPHP\\TOTP' ) ) { return \OTPHP\TOTP::create( $secret, self::PERIOD, clock: new SystemClock() )->at( $step * self::PERIOD ); } return $this->hotp( $secret, $step ); } catch ( \Throwable $e ) { return ''; } } /** * Generates a QR code data URI for TOTP setup. * * Uses the BaconQrCode library to render a styled SVG QR code with the plugin * logo in a round badge in the center. Falls back to a plain PNG (GD back end) * when the XMLWriter extension is missing. Returns an empty string if neither * back end is available, or rendering fails. * * @param string $data The data to encode in the QR code (typically otpauth URI). * * @return string SVG or PNG data URI of the QR code image, or empty string on failure. * * @since 1.0.0 */ protected function generate_qr_code( $data ): string { try { if ( class_exists( '\BaconQrCode\Writer' ) && class_exists( '\XMLWriter' ) ) { return 'data:image/svg+xml;base64,' . base64_encode( $this->render_svg_qr_code( $data ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding an SVG for a data URI. } if ( class_exists( '\BaconQrCode\Writer' ) && extension_loaded( 'gd' ) ) { $size = 300; // Margin is in modules. Error correction Q (~25%) keeps the code readable with the logo covering the center. $writer = new \BaconQrCode\Writer( new \BaconQrCode\Renderer\GDLibRenderer( $size, 2 ) ); $png = $writer->writeString( $data, \BaconQrCode\Encoder\Encoder::DEFAULT_BYTE_MODE_ENCODING, \BaconQrCode\Common\ErrorCorrectionLevel::Q() ); // Add logo to the center of the QR code. $logo_path = BRAIN_2FA_PLUGIN_DIR . 'assets/images/qr-logo.png'; if ( file_exists( $logo_path ) ) { $png = $this->add_qr_logo( $png, $logo_path, $size, 60 ); // Logo width (20% of QR code size). } return 'data:image/png;base64,' . base64_encode( $png ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a PNG for a data URI. } } catch ( \Throwable $e ) { do_action( 'brain2fa_qr_code_generation_failed', $e ); } // No external fallback — leaking the otpauth URI (which contains the TOTP secret) to a // third-party service would be a security issue. Return empty string; the view will show // the manual secret key entry field instead. return ''; } /** * Renders a styled QR code as SVG markup. * * Modules have rounded corners and the finder patterns are rounded squares, * all in black on white. The modules under the logo are cleared in a circle, * so the round badge never cuts a module in half; error correction level Q * (~25%) recovers the cleared area. * * @param string $data The data to encode in the QR code (typically otpauth URI). * * @return string SVG markup. * * @since 1.1.0 */ protected function render_svg_qr_code( string $data ): string { $size = 300; $margin = 2; // In modules. $qr_code = \BaconQrCode\Encoder\Encoder::encode( $data, \BaconQrCode\Common\ErrorCorrectionLevel::Q(), \BaconQrCode\Encoder\Encoder::DEFAULT_BYTE_MODE_ENCODING ); $modules = $qr_code->getMatrix()->getWidth(); $logo_path = BRAIN_2FA_PLUGIN_DIR . 'assets/images/qr-logo.png'; $has_logo = file_exists( $logo_path ); $radius = $modules * 0.15; // Badge radius in modules: the badge spans 30% of the code. if ( $has_logo ) { $matrix = $qr_code->getMatrix(); $center = ( $modules - 1 ) / 2; for ( $y = 0; $y < $modules; $y++ ) { for ( $x = 0; $x < $modules; $x++ ) { if ( ( ( $x - $center ) ** 2 ) + ( ( $y - $center ) ** 2 ) <= ( ( $radius + 0.5 ) ** 2 ) ) { $matrix->set( $x, $y, 0 ); } } } } $fill = \BaconQrCode\Renderer\RendererStyle\Fill::uniformColor( new \BaconQrCode\Renderer\Color\Rgb( 255, 255, 255 ), new \BaconQrCode\Renderer\Color\Rgb( 0, 0, 0 ) ); $style = new \BaconQrCode\Renderer\RendererStyle\RendererStyle( $size, $margin, new \BaconQrCode\Renderer\Module\RoundnessModule( \BaconQrCode\Renderer\Module\RoundnessModule::STRONG ), new QrRoundedEye(), $fill ); $renderer = new \BaconQrCode\Renderer\ImageRenderer( $style, new \BaconQrCode\Renderer\Image\SvgImageBackEnd() ); $svg = $renderer->render( $qr_code ); if ( ! $has_logo ) { return $svg; } // The code is centered, so the badge center is the image center. $center_px = $size / 2; $radius_px = $radius * $size / ( $modules + 2 * $margin ); $logo_px = $radius_px * 2 / 1.25; // The shield's corners stay inside the circle. $logo = base64_encode( (string) file_get_contents( $logo_path ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Embedding a local plugin asset in the SVG. $badge = sprintf( '', $center_px, $radius_px, $center_px - ( $logo_px / 2 ), $logo_px, $logo ); return str_replace( '', $badge . '', $svg ); } /** * Composites a logo onto the center of a QR code PNG. * * The logo is resized to the given width and drawn on a round white punchout * background for contrast. Returns the original PNG unchanged if the logo * cannot be loaded. * * @param string $png Binary PNG data of the QR code. * @param string $logo_path Absolute path to the logo image. * @param int $size QR code image size in pixels. * @param int $logo_width Target logo width in pixels. * * @return string Binary PNG data with the logo applied. * * @since 1.0.0 */ protected function add_qr_logo( string $png, string $logo_path, int $size, int $logo_width ): string { $qr = imagecreatefromstring( $png ); $logo = imagecreatefromstring( (string) file_get_contents( $logo_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin asset. if ( ! $qr || ! $logo ) { return $png; } $logo_height = (int) round( imagesy( $logo ) * $logo_width / imagesx( $logo ) ); $x = (int) ( ( $size - $logo_width ) / 2 ); $y = (int) ( ( $size - $logo_height ) / 2 ); // The circle is 25% wider than the logo so the shield's corners stay inside it. $diameter = (int) round( max( $logo_width, $logo_height ) * 1.25 ); imagefilledellipse( $qr, (int) ( $size / 2 ), (int) ( $size / 2 ), $diameter, $diameter, imagecolorallocate( $qr, 255, 255, 255 ) ); imagecopyresampled( $qr, $logo, $x, $y, 0, 0, $logo_width, $logo_height, imagesx( $logo ), imagesy( $logo ) ); ob_start(); imagepng( $qr ); return (string) ob_get_clean(); } /** * Generates a random base32 string. * * Creates a cryptographically secure random string using the base32 alphabet. * Used for generating TOTP secret keys when the OTPHP library is not available. * * @param int $length The length of the base32 string to generate. Default is 16. * * @return string A random base32-encoded string. * * @since 1.0.0 */ protected function random_base32( $length = 16 ) { $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $s = ''; for ( $i = 0; $i < $length; $i++ ) { $s .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ]; } return $s; } /** * Decodes a Base32 encoded string to binary data. * * This method converts a Base32 encoded string (RFC 4648) back to its original * binary representation. Base32 encoding uses a 32-character alphabet consisting * of uppercase letters A-Z and digits 2-7. * * @param string $b32 The Base32 encoded string to decode. * * @return string The decoded binary data as a string. * * @since 1.0.0 */ protected function base32_decode( $b32 ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $b32 = strtoupper( $b32 ); $l = strlen( $b32 ); $n = 0; $j = 0; $binary = ''; for ( $i = 0; $i < $l; $i++ ) { $n = ( $n << 5 ) + strpos( $alphabet, $b32[ $i ] ); $j += 5; if ( $j >= 8 ) { $j -= 8; $binary .= chr( ( $n & ( 0xFF << $j ) ) >> $j ); } } return $binary; } /** * Generates an HOTP (HMAC-based One-Time Password) token. * * Implements the HOTP algorithm as defined in RFC 4226. Uses HMAC-SHA1 * to generate a one-time password based on a shared secret and counter value. * * @param string $secret The base32-encoded shared secret key. * @param int $counter The counter value for HOTP generation. * @param int $digits The number of digits in the generated token. Default 6. * * @return string The generated HOTP token, zero-padded to specified digits. * * @since 1.0.0 */ protected function hotp( $secret, $counter, $digits = 6 ) { $key = $this->base32_decode( $secret ); $counter_bytes = pack( 'N*', 0 ) . pack( 'N*', $counter ); $hash = hash_hmac( 'sha1', $counter_bytes, $key, true ); $offset = ord( $hash[19] ) & 0xf; $truncated = substr( $hash, $offset, 4 ); $value = unpack( 'N', $truncated )[1] & 0x7fffffff; $mod = pow( 10, $digits ); return (string) str_pad( (string) ( $value % $mod ), $digits, '0', STR_PAD_LEFT ); } /** * Generates recovery codes for the user. * * Creates 10 unique recovery codes that can be used as backup authentication. * Each code is 8 characters long using alphanumeric characters. * * @param WP_User $user The WordPress user object to generate codes for. * * @return array Array of recovery codes. * * @since 1.0.0 */ public function generate_recovery_codes( WP_User $user ): array { $codes = array(); $count = apply_filters( 'brain2fa_recovery_codes_count', 10 ); for ( $i = 0; $i < $count; $i++ ) { $codes[] = $this->generate_single_recovery_code(); } // Hash codes before storing. $hashed_codes = array_map( 'wp_hash_password', $codes ); update_user_meta( $user->ID, 'brain2fa_recovery_codes', $hashed_codes ); return $codes; } /** * Generates a single recovery code. * * Creates an 8-character alphanumeric code in format XXXX-XXXX. * * @return string The generated recovery code. * * @since 1.0.0 */ protected function generate_single_recovery_code(): string { $characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $code_part1 = ''; $code_part2 = ''; for ( $i = 0; $i < 4; $i++ ) { $code_part1 .= $characters[ random_int( 0, strlen( $characters ) - 1 ) ]; } for ( $i = 0; $i < 4; $i++ ) { $code_part2 .= $characters[ random_int( 0, strlen( $characters ) - 1 ) ]; } return $code_part1 . '-' . $code_part2; } /** * Verifies a recovery code for the user. * * Checks if the provided code matches any of the user's stored recovery codes. * If a match is found, the code is removed from storage (one-time use). * * @param WP_User $user The WordPress user object to verify the code for. * @param string $code The recovery code to verify. * * @return bool True if the code is valid, false otherwise. * * @since 1.0.0 */ protected function verify_recovery_code( WP_User $user, string $code ): bool { $stored_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true ); if ( empty( $stored_codes ) || ! is_array( $stored_codes ) ) { return false; } // Normalize the input code (remove spaces, dashes, convert to uppercase). $code = strtoupper( str_replace( array( ' ', '-' ), '', $code ) ); // Check each stored code. foreach ( $stored_codes as $index => $hashed_code ) { // Try both with and without dash for comparison. $code_with_dash = substr( $code, 0, 4 ) . '-' . substr( $code, 4 ); if ( wp_check_password( $code, $hashed_code ) || wp_check_password( $code_with_dash, $hashed_code ) ) { // Remove the used code. unset( $stored_codes[ $index ] ); update_user_meta( $user->ID, 'brain2fa_recovery_codes', array_values( $stored_codes ) ); return true; } } return false; } /** * Gets the remaining recovery codes for a user. * * @param WP_User $user The WordPress user object. * * @return int The number of remaining recovery codes. * * @since 1.0.0 */ public function get_recovery_codes_count( WP_User $user ): int { $stored_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true ); return is_array( $stored_codes ) ? count( $stored_codes ) : 0; } }