ID ) ) { return $user; } if ( ! self::password_login_can_challenge() ) { NLSSL_Core::log( 'two_factor_channel_blocked', 'critical', 'An administrator password login was blocked because the channel cannot complete TOTP.', array( 'user_id' => $user->ID ) ); return new WP_Error( 'nlssl_2fa_channel_blocked', __( 'This administrator account requires an interactive two-factor login.', 'neverlast-security-lite' ) ); } if ( self::account_locked( $user->ID ) ) { return new WP_Error( 'nlssl_2fa_rate_limited', __( 'Two-factor verification is temporarily unavailable for this account. Try again later.', 'neverlast-security-lite' ) ); } if ( NLSSL_Core::rate_hit( self::account_bucket( 'issue', $user->ID ), self::ACCOUNT_WINDOW, self::CHALLENGE_ISSUE_MAX, false ) ) { NLSSL_Core::log( 'two_factor_challenge_limited', 'warning', 'Two-factor challenge creation was temporarily limited.', array( 'user_id' => $user->ID ) ); return new WP_Error( 'nlssl_2fa_rate_limited', __( 'Two-factor verification is temporarily unavailable for this account. Try again later.', 'neverlast-security-lite' ) ); } try { $token = bin2hex( random_bytes( 32 ) ); } catch ( Throwable $error ) { return new WP_Error( 'nlssl_2fa_entropy_failed', __( 'Two-factor verification could not be started securely.', 'neverlast-security-lite' ) ); } $hash = hash( 'sha256', $token ); $expires = time() + 5 * MINUTE_IN_SECONDS; // The WordPress login form carries no nonce. This runs inside its authenticate filter, after the password // was accepted, and only keeps where the login form was going, limited to this site by wp_validate_redirect(). $redirect = wp_validate_redirect( self::input_scalar( $_REQUEST, 'redirect_to' ), admin_url() ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $state_hash = self::user_state_hash( $user ); global $wpdb; $table = NLSSL_Core::table( '2fa_challenges' ); $inserted = $wpdb->insert( $table, array( 'token_hash' => $hash, 'user_id' => $user->ID, 'created_at' => time(), 'expires_at' => $expires, 'attempts' => 0, 'consumed_at' => 0, 'remember_me' => '' === self::input_scalar( $_POST, 'rememberme' ) ? 0 : 1, // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The login form's own "Remember Me" box; the login form carries no nonce. 'state_hash' => $state_hash, 'redirect_to' => $redirect, ), array( '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s' ) ); $row = 1 === $inserted ? $wpdb->get_row( $wpdb->prepare( 'SELECT token_hash,user_id,expires_at,state_hash FROM %i WHERE token_hash = %s', $table, $hash ), ARRAY_A ) : null; if ( 1 !== $inserted || ! is_array( $row ) || ! empty( $wpdb->last_error ) || ! hash_equals( $hash, (string) $row['token_hash'] ) || $user->ID !== (int) $row['user_id'] || $expires !== (int) $row['expires_at'] || ! hash_equals( $state_hash, (string) $row['state_hash'] ) ) { $wpdb->delete( $table, array( 'token_hash' => $hash ), array( '%s' ) ); NLSSL_Core::degraded_storage( 'two_factor_challenge_write' ); return new WP_Error( 'nlssl_2fa_storage_failed', __( 'Two-factor verification could not be started because security storage was not confirmed.', 'neverlast-security-lite' ) ); } if ( ! self::set_cookie( $token, $expires ) ) { $wpdb->delete( $table, array( 'token_hash' => $hash ), array( '%s' ) ); return new WP_Error( 'nlssl_2fa_cookie_failed', __( 'Two-factor verification could not start because the secure challenge cookie was not accepted.', 'neverlast-security-lite' ) ); } $url = add_query_arg( 'action', 'nlssl_2fa', site_url( 'wp-login.php', 'login_post' ) ); if ( ! headers_sent() ) { wp_safe_redirect( $url ); exit; } return new WP_Error( 'nlssl_2fa_required', sprintf( /* translators: %s: address of the verification page */ __( 'Two-factor verification is required. Continue at %s', 'neverlast-security-lite' ), esc_url( $url ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function password_login_can_challenge(): bool { if ( 'POST' !== strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) ) { return false; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return false; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return false; } if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { return false; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } return true; } public static function render_challenge(): void { $token = self::cookie_token(); $row = self::challenge( $token ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only chooses whether to show the fixed "invalid code" message. $error = '' !== self::input_scalar( $_GET, 'nlssl_error' ) ? new WP_Error( 'nlssl_invalid_code', __( 'The code was invalid or expired. Try again.', 'neverlast-security-lite' ) ) : new WP_Error(); if ( ! $row ) { $error->add( 'nlssl_expired', __( 'The verification request expired. Sign in again.', 'neverlast-security-lite' ) ); } login_header( __( 'Two-factor verification', 'neverlast-security-lite' ), '', $error ); if ( $row ) { echo '
'; } login_footer(); exit; } public static function verify_challenge(): void { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reserves, reads and consumes the two-factor challenge atomically in the plugin's own table, so a code cannot be replayed; this must never be cached. if ( NLSSL_Login::is_frozen() ) { self::challenge_failure(); } $token = self::cookie_token(); $hash = hash( 'sha256', $token ); if ( '' === $token || ! isset( $_POST['nlssl_nonce'] ) || ! is_string( $_POST['nlssl_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nlssl_nonce'] ) ), 'nlssl_2fa_' . $hash ) ) { self::challenge_failure(); } $row = self::challenge( $token ); $user = ! empty( $row['user_id'] ) ? get_user_by( 'id', (int) $row['user_id'] ) : false; if ( ! $user instanceof WP_User || ! user_can( $user, 'manage_options' ) || ! self::enabled_for( $user->ID ) || self::account_locked( $user->ID ) ) { self::challenge_failure(); } global $wpdb; $table = NLSSL_Core::table( '2fa_challenges' ); $reserved = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET attempts = attempts + 1 WHERE token_hash = %s AND consumed_at = 0 AND expires_at >= %d AND attempts < 5', $table, $hash, time() ) ); if ( 1 !== $reserved ) { if ( false === $reserved || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_reserve' ); } self::challenge_failure(); } $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token_hash = %s', $table, $hash ), ARRAY_A ); if ( ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_read' ); self::challenge_failure(); } $user = is_array( $row ) ? get_user_by( 'id', (int) $row['user_id'] ) : false; if ( ! $user instanceof WP_User || ! isset( $row['state_hash'] ) || ! hash_equals( self::user_state_hash( $user ), (string) $row['state_hash'] ) || ! user_can( $user, 'manage_options' ) || ! self::enabled_for( $user->ID ) ) { self::challenge_failure(); } $code = preg_replace( '/[^A-Z0-9-]/', '', strtoupper( self::input_scalar( $_POST, 'nlssl_code' ) ) ); if ( ! is_array( $row ) || ! self::verify_user_code( (int) $row['user_id'], $code, true ) ) { self::register_account_failure( (int) ( $row['user_id'] ?? 0 ) ); NLSSL_Core::log( 'two_factor_failed', 'warning', 'A two-factor challenge failed.', array( 'user_id' => (int) ( $row['user_id'] ?? 0 ) ) ); self::challenge_failure(); } $consumed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET consumed_at = %d WHERE token_hash = %s AND consumed_at = 0', $table, time(), $hash ) ); if ( 1 !== $consumed ) { if ( false === $consumed || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_consume' ); } self::challenge_failure(); } $user = get_user_by( 'id', (int) $row['user_id'] ); if ( ! $user instanceof WP_User || ! hash_equals( self::user_state_hash( $user ), (string) $row['state_hash'] ) || NLSSL_Login::is_frozen() || ! user_can( $user, 'manage_options' ) || ! self::enabled_for( $user->ID ) ) { self::challenge_failure(); } if ( false === $wpdb->delete( $table, array( 'user_id' => $user->ID ), array( '%d' ) ) || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_delete' ); self::challenge_failure(); } self::clear_account_limits( $user->ID ); wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, ! empty( $row['remember_me'] ), self::secure_cookie_context() ); do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core's own hook: the second factor completes the login, so it fires exactly as wp_signon() would. self::clear_cookie(); NLSSL_Core::log( 'two_factor_success', 'info', 'A two-factor challenge was completed.', array( 'user_id' => $user->ID ) ); wp_safe_redirect( wp_validate_redirect( (string) $row['redirect_to'], admin_url() ) ); exit; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function challenge_failure(): void { wp_safe_redirect( add_query_arg( array( 'action' => 'nlssl_2fa', 'nlssl_error' => '1' ), site_url( 'wp-login.php', 'login_post' ) ) ); exit; } private static function account_bucket( string $purpose, int $user_id ): string { $purpose = in_array( $purpose, array( 'issue', 'failure', 'lock', 'maintenance', 'reauth' ), true ) ? $purpose : 'unknown'; $identity = substr( hash_hmac( 'sha256', 'user:' . max( 0, $user_id ), wp_salt( 'nonce' ) ), 0, 24 ); return 'login_2fa_' . $purpose . '_' . $identity; } private static function account_locked( int $user_id ): bool { return $user_id > 0 && NLSSL_Core::rate_limited( self::account_bucket( 'lock', $user_id ), 1, false ); } private static function register_account_failure( int $user_id ): void { if ( $user_id <= 0 ) { return; } if ( NLSSL_Core::rate_hit( self::account_bucket( 'failure', $user_id ), self::ACCOUNT_WINDOW, self::ACCOUNT_FAILURE_MAX - 1, false ) ) { NLSSL_Core::rate_hit( self::account_bucket( 'lock', $user_id ), self::ACCOUNT_WINDOW, 0, false ); } } private static function sensitive_action_limited( string $purpose, int $user_id ): bool { return $user_id > 0 && NLSSL_Core::rate_limited( self::account_bucket( $purpose, $user_id ), self::SENSITIVE_ACTION_MAX, false ); } private static function register_sensitive_action_failure( string $purpose, int $user_id ): bool { return $user_id <= 0 || NLSSL_Core::rate_hit( self::account_bucket( $purpose, $user_id ), self::ACCOUNT_WINDOW, self::SENSITIVE_ACTION_MAX - 1, false ); } private static function clear_account_limits( int $user_id ): void { foreach ( array( 'issue', 'failure', 'lock', 'maintenance', 'reauth' ) as $purpose ) { NLSSL_Core::rate_clear( self::account_bucket( $purpose, $user_id ), false ); } } private static function challenge( string $token ): array { if ( ! preg_match( '/^[a-f0-9]{64}$/D', $token ) ) { return array(); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads an unconsumed two-factor challenge from the plugin's own table; it must be current. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token_hash = %s AND consumed_at = 0', NLSSL_Core::table( '2fa_challenges' ), hash( 'sha256', $token ) ), ARRAY_A ); if ( ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_read' ); return array(); } if ( ! is_array( $row ) || (int) $row['expires_at'] < time() || (int) $row['attempts'] >= 5 ) { return array(); } $user = get_user_by( 'id', (int) $row['user_id'] ); if ( ! $user instanceof WP_User || ! isset( $row['state_hash'] ) || ! hash_equals( self::user_state_hash( $user ), (string) $row['state_hash'] ) ) { return array(); } return $row; } private static function user_state_hash( WP_User $user ): string { $roles = array_map( 'strval', (array) $user->roles ); sort( $roles, SORT_STRING ); return hash_hmac( 'sha256', $user->ID . '|' . $user->user_pass . '|' . implode( ',', $roles ), wp_salt( 'auth' ) ); } private static function cookie_token(): string { $token = sanitize_text_field( self::input_scalar( $_COOKIE, self::COOKIE ) ); return preg_match( '/^[a-f0-9]{64}$/D', $token ) ? $token : ''; } private static function secure_cookie_context(): bool { $site_https = function_exists( 'site_url' ) && 'https' === wp_parse_url( site_url(), PHP_URL_SCHEME ); return is_ssl() || $site_https; } private static function set_cookie( string $token, int $expires ): bool { $options = array( 'expires' => $expires, 'path' => defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/', 'secure' => self::secure_cookie_context(), 'httponly' => true, 'samesite' => 'Lax' ); if ( defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ) { $options['domain'] = COOKIE_DOMAIN; } return setcookie( self::COOKIE, $token, $options ); } private static function clear_cookie(): void { self::set_cookie( '', time() - HOUR_IN_SECONDS ); unset( $_COOKIE[ self::COOKIE ] ); } private static function require_admin_user(): int { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to perform this action.', 'neverlast-security-lite' ), 403 ); } return get_current_user_id(); } public static function start_enrollment(): void { $user_id = self::require_admin_user(); check_admin_referer( 'nlssl_totp_start' ); if ( self::configured_for( $user_id ) ) { self::admin_redirect( 'already_configured' ); } if ( ! self::available() ) { self::admin_redirect( 'crypto_unavailable' ); } try { $secret = self::base32_encode( random_bytes( 20 ) ); } catch ( Throwable $error ) { self::admin_redirect( 'totp_generation_failed' ); } $sealed = self::seal( $secret ); if ( '' === $sealed || ! self::write_user_meta_verified( $user_id, self::PENDING, $sealed ) ) { self::admin_redirect( 'totp_storage_failed' ); } self::admin_redirect( 'setup_started' ); } public static function confirm_enrollment(): void { $user_id = self::require_admin_user(); check_admin_referer( 'nlssl_totp_confirm' ); if ( self::configured_for( $user_id ) ) { self::admin_redirect( 'already_configured' ); } $pending = self::open( (string) get_user_meta( $user_id, self::PENDING, true ) ); $code = preg_replace( '/\D/', '', self::input_scalar( $_POST, 'nlssl_code' ) ); if ( '' === $pending || ! self::verify_totp( $pending, $code ) ) { self::admin_redirect( 'invalid_setup_code' ); } $sealed = self::seal( $pending ); $stored = '' !== $sealed && self::write_user_meta_verified( $user_id, self::RECOVERY_CONFIRMED, '0' ) && self::write_user_meta_verified( $user_id, self::SECRET, $sealed ) && self::generate_backup_codes( $user_id ) && self::write_user_meta_verified( $user_id, self::ENABLED, '1' ) && self::delete_user_meta_verified( $user_id, self::PENDING ); if ( ! $stored ) { self::rollback_enrollment( $user_id ); self::admin_redirect( 'totp_storage_failed' ); } NLSSL_Core::log( 'two_factor_verified', 'warning', 'Administrator TOTP was verified and awaits recovery-code acknowledgement.', array( 'user_id' => $user_id ) ); self::admin_redirect( 'enabled' ); } public static function acknowledge_recovery_codes(): void { $user_id = self::require_admin_user(); check_admin_referer( 'nlssl_totp_acknowledge' ); if ( '1' !== self::input_scalar( $_POST, 'codes_saved' ) || ! self::awaiting_recovery_confirmation( $user_id ) || self::backup_count( $user_id ) < 1 ) { self::admin_redirect( 'codes_confirmation_required' ); } if ( ! self::write_user_meta_verified( $user_id, self::RECOVERY_CONFIRMED, '1' ) ) { self::admin_redirect( 'totp_storage_failed' ); } NLSSL_Core::log( 'two_factor_enabled', 'warning', 'Administrator TOTP was enabled after recovery-code acknowledgement.', array( 'user_id' => $user_id ) ); self::admin_redirect( 'recovery_confirmed' ); } public static function disable_for_current_user(): void { $user_id = self::require_admin_user(); check_admin_referer( 'nlssl_totp_disable' ); if ( self::sensitive_action_limited( 'maintenance', $user_id ) ) { self::admin_redirect( 'totp_rate_limited' ); } $code = preg_replace( '/[^A-Z0-9-]/', '', strtoupper( self::input_scalar( $_POST, 'nlssl_code' ) ) ); if ( ! self::verify_user_code( $user_id, $code, true ) ) { self::admin_redirect( self::register_sensitive_action_failure( 'maintenance', $user_id ) ? 'totp_rate_limited' : 'invalid_disable_code' ); } NLSSL_Core::rate_clear( self::account_bucket( 'maintenance', $user_id ), false ); if ( ! self::delete_user_state( $user_id ) ) { self::admin_redirect( 'totp_storage_failed' ); } NLSSL_Core::log( 'two_factor_disabled', 'critical', 'Administrator TOTP was disabled.', array( 'user_id' => $user_id ) ); self::admin_redirect( 'disabled' ); } public static function regenerate_backups(): void { $user_id = self::require_admin_user(); check_admin_referer( 'nlssl_totp_regenerate' ); if ( self::sensitive_action_limited( 'maintenance', $user_id ) ) { self::admin_redirect( 'totp_rate_limited' ); } $code = preg_replace( '/[^A-Z0-9-]/', '', strtoupper( self::input_scalar( $_POST, 'nlssl_code' ) ) ); if ( ! self::verify_user_code( $user_id, $code, false ) ) { self::admin_redirect( self::register_sensitive_action_failure( 'maintenance', $user_id ) ? 'totp_rate_limited' : 'invalid_regenerate_code' ); } NLSSL_Core::rate_clear( self::account_bucket( 'maintenance', $user_id ), false ); if ( ! self::generate_backup_codes( $user_id ) ) { self::admin_redirect( 'totp_storage_failed' ); } NLSSL_Core::log( 'two_factor_recovery_regenerated', 'warning', 'Administrator recovery codes were regenerated.', array( 'user_id' => $user_id ) ); self::admin_redirect( 'codes_regenerated' ); } public static function admin_reset(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden', 'neverlast-security-lite' ), 403 ); } $user_id = absint( self::input_scalar( $_POST, 'user_id' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is bound to the target account, so its id is read first and verified on the next line. check_admin_referer( 'nlssl_totp_admin_reset_' . $user_id ); if ( $user_id <= 0 || $user_id === get_current_user_id() || ! user_can( $user_id, 'manage_options' ) || ! current_user_can( 'edit_user', $user_id ) ) { wp_die( esc_html__( 'Invalid administrator account.', 'neverlast-security-lite' ), 400 ); } $actor = wp_get_current_user(); $actor_id = get_current_user_id(); if ( self::sensitive_action_limited( 'reauth', $actor_id ) ) { self::admin_redirect( 'admin_reset_rate_limited' ); } $password = substr( self::input_scalar( $_POST, 'current_password' ), 0, 4096 ); if ( ! $actor instanceof WP_User || '' === $password || ! wp_check_password( $password, $actor->user_pass, $actor->ID ) ) { NLSSL_Core::log( 'two_factor_admin_reset_failed', 'critical', 'An administrator TOTP reset was rejected because reauthentication failed.', array( 'user_id' => $user_id, 'actor' => get_current_user_id() ) ); self::admin_redirect( self::register_sensitive_action_failure( 'reauth', $actor_id ) ? 'admin_reset_rate_limited' : 'invalid_admin_reset_credentials' ); } NLSSL_Core::rate_clear( self::account_bucket( 'reauth', $actor_id ), false ); if ( ! self::delete_user_state( $user_id ) ) { self::admin_redirect( 'totp_storage_failed' ); } NLSSL_Core::log( 'two_factor_admin_reset', 'critical', 'An administrator reset another administrator account two-factor state.', array( 'user_id' => $user_id, 'actor' => get_current_user_id() ) ); self::admin_redirect( 'admin_reset' ); } private static function admin_redirect( string $notice ): void { wp_safe_redirect( add_query_arg( array( 'page' => 'nlssl-login', 'nlssl_notice' => sanitize_key( $notice ) ), admin_url( 'admin.php' ) ) ); exit; } private static function persisted_user_meta_values( int $user_id, string $key ): array|WP_Error { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the stored recovery-code metadata directly to verify what is persisted, bypassing the object cache on purpose. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id ASC", $user_id, $key ) ); if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_meta_read' ); return new WP_Error( 'nlssl_2fa_storage_failed', 'Two-factor storage could not be verified.' ); } return array_map( 'maybe_unserialize', $rows ); } private static function meta_value_matches( $stored, $expected ): bool { return is_scalar( $expected ) ? is_scalar( $stored ) && (string) $stored === (string) $expected : $stored === $expected; } private static function write_user_meta_verified( int $user_id, string $key, $value ): bool { update_user_meta( $user_id, $key, $value ); wp_cache_delete( $user_id, 'user_meta' ); $rows = self::persisted_user_meta_values( $user_id, $key ); $ok = is_array( $rows ) && ! empty( $rows ); if ( $ok ) { foreach ( $rows as $stored ) { if ( ! self::meta_value_matches( $stored, $value ) ) { $ok = false; break; } } } if ( ! $ok ) { NLSSL_Core::degraded_storage( 'two_factor_meta_write' ); } return $ok; } private static function delete_user_meta_verified( int $user_id, string $key ): bool { delete_user_meta( $user_id, $key ); wp_cache_delete( $user_id, 'user_meta' ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Confirms in the database that a two-factor secret is really gone after delete_user_meta(), bypassing the object cache on purpose; a cached answer could report it removed when it is not. $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s", $user_id, $key ) ); $ok = null !== $count && empty( $wpdb->last_error ) && 0 === (int) $count; if ( ! $ok ) { NLSSL_Core::degraded_storage( 'two_factor_meta_delete' ); } return $ok; } private static function rollback_enrollment( int $user_id ): void { self::write_user_meta_verified( $user_id, self::RECOVERY_CONFIRMED, '0' ); foreach ( array( self::SECRET, self::ENABLED, self::BACKUPS, self::BACKUPS_ONCE, self::BACKUPS_EXPIRES, self::RECOVERY_CONFIRMED, self::LAST_COUNTER ) as $key ) { self::delete_user_meta_verified( $user_id, $key ); } } private static function delete_user_state( int $user_id ): bool { $ok = self::write_user_meta_verified( $user_id, self::RECOVERY_CONFIRMED, '0' ); $ok = self::invalidate_challenges( $user_id ) && $ok; foreach ( array( self::SECRET, self::PENDING, self::ENABLED, self::BACKUPS, self::BACKUPS_ONCE, self::BACKUPS_EXPIRES, self::RECOVERY_CONFIRMED, self::LAST_COUNTER ) as $key ) { $ok = self::delete_user_meta_verified( $user_id, $key ) && $ok; } self::clear_account_limits( $user_id ); return $ok; } public static function password_changed( string $password, int $user_id ): void { self::invalidate_challenges( $user_id ); } public static function invalidate_challenges( int $user_id ): bool { if ( $user_id <= 0 ) { return true; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Invalidates an account's pending two-factor challenges in the plugin's own table as soon as its role or password changes. $deleted = $wpdb->delete( NLSSL_Core::table( '2fa_challenges' ), array( 'user_id' => $user_id ), array( '%d' ) ); if ( false === $deleted || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_challenge_delete' ); return false; } return true; } public static function pending_secret( int $user_id ): string { return self::open( (string) get_user_meta( $user_id, self::PENDING, true ) ); } public static function recovery_codes_once( int $user_id ): array { $expires = (int) get_user_meta( $user_id, self::BACKUPS_EXPIRES, true ); $stored = get_user_meta( $user_id, self::BACKUPS_ONCE, true ); if ( ! self::delete_user_meta_verified( $user_id, self::BACKUPS_ONCE ) || ! self::delete_user_meta_verified( $user_id, self::BACKUPS_EXPIRES ) ) { return array(); } if ( is_array( $stored ) ) { $codes = $stored; } else { $decoded = json_decode( self::open( (string) $stored ), true ); $codes = is_array( $decoded ) ? $decoded : array(); } return $expires >= time() && is_array( $codes ) ? $codes : array(); } public static function backup_count( int $user_id ): int { $codes = get_user_meta( $user_id, self::BACKUPS, true ); return is_array( $codes ) ? count( $codes ) : 0; } private static function generate_backup_codes( int $user_id ): bool { $plain = array(); $hashes = array(); try { for ( $i = 0; $i < 10; $i++ ) { $raw = strtoupper( bin2hex( random_bytes( 5 ) ) ); $code = substr( $raw, 0, 5 ) . '-' . substr( $raw, 5 ); $plain[] = $code; $hashes[] = wp_hash_password( $code ); } } catch ( Throwable $error ) { return false; } $sealed = self::seal( wp_json_encode( $plain ) ); $expires = time() + 10 * MINUTE_IN_SECONDS; if ( '' === $sealed || ! self::write_user_meta_verified( $user_id, self::BACKUPS_ONCE, $sealed ) || ! self::write_user_meta_verified( $user_id, self::BACKUPS_EXPIRES, $expires ) ) { self::delete_user_meta_verified( $user_id, self::BACKUPS_ONCE ); self::delete_user_meta_verified( $user_id, self::BACKUPS_EXPIRES ); return false; } if ( ! self::write_user_meta_verified( $user_id, self::BACKUPS, $hashes ) ) { self::delete_user_meta_verified( $user_id, self::BACKUPS_ONCE ); self::delete_user_meta_verified( $user_id, self::BACKUPS_EXPIRES ); return false; } return true; } private static function verify_user_code( int $user_id, string $code, bool $consume_backup ): bool { $secret = self::open( (string) get_user_meta( $user_id, self::SECRET, true ) ); $counter = '' !== $secret ? self::matching_totp_counter( $secret, preg_replace( '/\D/', '', $code ) ) : null; if ( null !== $counter && self::consume_totp_counter( $user_id, $counter ) ) { return true; } return $consume_backup && self::consume_backup_code( $user_id, $code ); } private static function consume_totp_counter( int $user_id, int $counter ): bool { $current_raw = get_user_meta( $user_id, self::LAST_COUNTER, true ); if ( '' === (string) $current_raw ) { return add_user_meta( $user_id, self::LAST_COUNTER, (string) $counter, true ); } if ( $counter <= (int) $current_raw ) { return false; } return false !== update_user_meta( $user_id, self::LAST_COUNTER, (string) $counter, $current_raw ); } private static function consume_backup_code( int $user_id, string $code ): bool { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Consumes a one-use recovery code with an atomic compare-and-swap on the stored value, so two concurrent logins cannot both use it; WordPress has no compare-and-swap API. global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT umeta_id, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s LIMIT 1", $user_id, self::BACKUPS ), ARRAY_A ); if ( ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_backup_read' ); return false; } if ( ! is_array( $row ) ) { return false; } $codes = maybe_unserialize( $row['meta_value'] ); if ( ! is_array( $codes ) ) { return false; } foreach ( $codes as $index => $hash ) { if ( wp_check_password( $code, (string) $hash ) ) { unset( $codes[ $index ] ); $new_value = maybe_serialize( array_values( $codes ) ); $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->usermeta} SET meta_value = %s WHERE umeta_id = %d AND meta_value = %s", $new_value, (int) $row['umeta_id'], (string) $row['meta_value'] ) ); if ( 1 === $updated ) { wp_cache_delete( $user_id, 'user_meta' ); } elseif ( false === $updated || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'two_factor_backup_consume' ); } return 1 === $updated; } } return false; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function seal( string $secret ): string { if ( ! self::available() ) { return ''; } // Key-derivation label for stored TOTP secrets. Never rename it: a different label derives a // different key, and every enrolled administrator would be locked out of two-factor login. $key = hash( 'sha256', wp_salt( 'auth' ) . '|sentinel-lite-totp', true ); try { $iv = random_bytes( 12 ); } catch ( Throwable $error ) { return ''; } $tag = ''; $cipher = openssl_encrypt( $secret, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 ); if ( false === $cipher ) { return ''; } return 'v1.' . rtrim( strtr( base64_encode( $iv . $tag . $cipher ), '+/', '-_' ), '=' ); } private static function open( string $sealed ): string { if ( ! self::available() || ! str_starts_with( $sealed, 'v1.' ) ) { return ''; } $encoded = substr( $sealed, 3 ); $encoded .= str_repeat( '=', ( 4 - strlen( $encoded ) % 4 ) % 4 ); $raw = base64_decode( strtr( $encoded, '-_', '+/' ), true ); if ( ! is_string( $raw ) || strlen( $raw ) < 29 ) { return ''; } $iv = substr( $raw, 0, 12 ); $tag = substr( $raw, 12, 16 ); $cipher = substr( $raw, 28 ); // Key-derivation label for stored TOTP secrets. Never rename it: a different label derives a // different key, and every enrolled administrator would be locked out of two-factor login. $key = hash( 'sha256', wp_salt( 'auth' ) . '|sentinel-lite-totp', true ); $plain = openssl_decrypt( $cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '' ); return is_string( $plain ) ? $plain : ''; } public static function verify_totp( string $secret, string $code, ?int $now = null ): bool { return null !== self::matching_totp_counter( $secret, $code, $now ); } private static function matching_totp_counter( string $secret, string $code, ?int $now = null ): ?int { if ( ! preg_match( '/^\d{6}$/D', $code ) ) { return null; } if ( '' === self::base32_decode( $secret ) ) { return null; } $now = $now ?? time(); foreach ( array( -1, 0, 1 ) as $window ) { $timestamp = $now + $window * 30; if ( hash_equals( self::totp_at( $secret, $timestamp ), $code ) ) { return intdiv( $timestamp, 30 ); } } return null; } public static function totp_at( string $secret, int $timestamp ): string { $key = self::base32_decode( $secret ); // No code at all, rather than a fixed one: a six-digit code can never match an empty string. if ( '' === $key ) { return ''; } $counter = intdiv( $timestamp, 30 ); $binary_counter = pack( 'N2', ( $counter >> 32 ) & 0xffffffff, $counter & 0xffffffff ); $hash = hash_hmac( 'sha1', $binary_counter, $key, true ); $offset = ord( $hash[19] ) & 0x0f; $value = unpack( 'N', substr( $hash, $offset, 4 ) )[1] & 0x7fffffff; return str_pad( (string) ( $value % 1000000 ), 6, '0', STR_PAD_LEFT ); } public static function base32_encode( string $binary ): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $bits = ''; foreach ( str_split( $binary ) as $char ) { $bits .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT ); } $out = ''; foreach ( str_split( $bits, 5 ) as $chunk ) { $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT ); $out .= $alphabet[ bindec( $chunk ) ]; } return $out; } public static function base32_decode( string $encoded ): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $encoded = strtoupper( preg_replace( '/[\s=-]/', '', $encoded ) ); if ( '' === $encoded || preg_match( '/[^A-Z2-7]/', $encoded ) ) { return ''; } $bits = ''; foreach ( str_split( $encoded ) as $char ) { $position = strpos( $alphabet, $char ); if ( false === $position ) { return ''; } $bits .= str_pad( decbin( $position ), 5, '0', STR_PAD_LEFT ); } $out = ''; foreach ( str_split( $bits, 8 ) as $byte ) { if ( strlen( $byte ) < 8 ) { break; } $out .= chr( bindec( $byte ) ); } return $out; } public static function otpauth_uri( int $user_id, string $secret ): string { $user = get_user_by( 'id', $user_id ); $label = $user instanceof WP_User ? $user->user_login . '@' . wp_parse_url( home_url(), PHP_URL_HOST ) : 'wordpress'; return 'otpauth://totp/' . rawurlencode( 'NeverLast Security Lite:' . $label ) . '?secret=' . rawurlencode( $secret ) . '&issuer=' . rawurlencode( 'NeverLast Security Lite' ) . '&digits=6&period=30'; } }