128 ) { return 'too_long'; }
if ( trim( $secret ) !== $secret || preg_match( '/[\x00-\x1F\x7F]/', $secret ) ) { return 'invalid_characters'; }
return '';
}
public static function replace_secret( string $current, string $new_secret ): WP_Error|bool {
$error = self::secret_validation_error( $new_secret );
if ( '' !== $error ) { return new WP_Error( 'nlssl_' . $error, __( 'Use a secret phrase between 6 and 128 characters without leading, trailing or control characters.', 'neverlast-security-lite' ) ); }
$stored = NLSSL_Core::persisted_option( self::SECRET_OPTION, '' );
if ( is_wp_error( $stored ) ) { return $stored; }
$stored = (string) $stored;
if ( '' !== $stored && ( '' === $current || ! wp_check_password( $current, $stored ) ) ) {
return new WP_Error( 'nlssl_current_secret_invalid', __( 'The current emergency secret was not valid.', 'neverlast-security-lite' ) );
}
if ( ! NLSSL_Core::write_option_verified( self::SECRET_OPTION, wp_hash_password( $new_secret ) ) ) {
return new WP_Error( 'nlssl_storage_failed', __( 'The emergency-secret save was not confirmed. Repair storage and verify which secret is active before using emergency links.', 'neverlast-security-lite' ) );
}
return true;
}
public static function login_response_link( int $user_id ): string {
if ( ! NLSSL_Core::setting( 'emergency_email_enabled', 0 ) || ! self::configured() || $user_id <= 0 ) { return ''; }
$user = get_userdata( $user_id );
if ( ! $user instanceof WP_User || ! user_can( $user, 'manage_options' ) ) { return ''; }
$lock = 'nlssl_emergency_lock_' . $user_id;
if ( ! NLSSL_Core::acquire_lock( $lock, 10 ) ) { return ''; }
try {
$active_key = 'nlssl_emergency_active_' . $user_id;
$active = get_transient( $active_key );
if ( is_array( $active ) && (int) ( $active['expires'] ?? 0 ) > time() && is_string( $active['challenge'] ?? null ) ) {
$existing = get_transient( self::token_key( $active['challenge'] ) );
if ( is_array( $existing ) && $user_id === (int) ( $existing['user_id'] ?? 0 ) ) {
return self::link_url( $user_id, (int) $active['expires'], $active['challenge'] );
}
}
$expires = time() + self::TOKEN_TTL;
try { $nonce = bin2hex( random_bytes( 16 ) ); }
catch ( Throwable $error ) { NLSSL_Core::degraded_storage( 'emergency_token_entropy' ); return ''; }
if ( ! NLSSL_Core::write_transient_verified( self::token_key( $nonce ), array( 'user_id' => $user_id, 'expires' => $expires ), self::TOKEN_TTL ) ) { NLSSL_Core::degraded_storage( 'emergency_token_write' ); return ''; }
if ( ! NLSSL_Core::write_transient_verified( $active_key, array( 'challenge' => $nonce, 'expires' => $expires ), self::TOKEN_TTL ) ) {
delete_transient( self::token_key( $nonce ) );
NLSSL_Core::degraded_storage( 'emergency_active_token_write' );
return '';
}
return self::link_url( $user_id, $expires, $nonce );
} finally { NLSSL_Core::release_lock( $lock ); }
}
private static function link_url( int $user_id, int $expires, string $nonce ): string {
$signature = hash_hmac( 'sha256', self::payload( $user_id, $expires, $nonce ), wp_salt( 'auth' ) );
return add_query_arg(
array(
'action' => 'nlssl_emergency_freeze',
'uid' => $user_id,
'expires' => $expires,
'challenge' => $nonce,
'signature' => $signature,
),
admin_url( 'admin-post.php' )
);
}
public static function handle_freeze(): void {
$state = self::validate_request();
if ( is_wp_error( $state ) ) { self::render( $state->get_error_message(), false, (int) ( $state->get_error_data() ?: 403 ) ); }
if ( 'POST' !== strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) ) {
self::render_form( $state );
}
$attempt_bucket = 'emergency_secret_attempt_' . substr( hash( 'sha256', $state['challenge'] ), 0, 24 );
if ( NLSSL_Core::rate_hit( $attempt_bucket, 15 * MINUTE_IN_SECONDS, 5, false ) ) {
self::render( __( 'Too many emergency-secret attempts. Wait before trying again.', 'neverlast-security-lite' ), false, 429 );
}
// The request was already authorised by validate_request(): a signed, single-use, 15-minute token, because
// the person using an emergency link may not be logged in and so cannot hold a WordPress nonce. The secret is
// checked against its hash byte for byte, like a password; sanitizing would silently change it.
// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$secret = isset( $_POST['emergency_secret'] ) && is_string( $_POST['emergency_secret'] ) ? wp_unslash( $_POST['emergency_secret'] ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$stored = (string) get_option( self::SECRET_OPTION, '' );
if ( '' === $stored || '' === $secret || ! wp_check_password( $secret, $stored ) ) {
NLSSL_Core::log( 'emergency_secret_rejected', 'critical', 'An emergency login-freeze request used an invalid secret.' );
self::render( __( 'The emergency secret was not valid. No action was taken.', 'neverlast-security-lite' ), false, 403 );
}
$lock = 'nlssl_emergency_action_' . substr( hash( 'sha256', $state['challenge'] ), 0, 32 );
if ( ! NLSSL_Core::acquire_lock( $lock, 30 ) ) { self::render( __( 'This action is busy or storage is unavailable. Retry the same link.', 'neverlast-security-lite' ), false, 503 ); }
$status = 503;
$success = false;
try {
$state = self::validate_request();
if ( is_wp_error( $state ) ) { $message = $state->get_error_message(); $status = (int) ( $state->get_error_data() ?: 403 ); }
else {
$minutes = max( 5, min( 1440, (int) NLSSL_Core::setting( 'login_freeze_minutes', 60 ) ) );
$result = NLSSL_Login::freeze( $minutes );
if ( is_wp_error( $result ) ) { $message = $result->get_error_message(); }
elseif ( ! self::claim_token( $state ) ) { $message = __( 'Login is frozen and sessions were invalidated, but link consumption was not confirmed. Retry after storage recovery.', 'neverlast-security-lite' ); }
else {
delete_transient( self::token_key( $state['challenge'] ) );
delete_transient( 'nlssl_emergency_active_' . $state['user_id'] );
$success = true; $status = 200;
$message = sprintf( /* translators: %d: number of minutes the login freeze lasts */ __( 'Emergency Login Freeze is active for at least %d minutes. All WordPress sessions were invalidated.', 'neverlast-security-lite' ), $minutes );
}
}
} finally { NLSSL_Core::release_lock( $lock ); }
self::render( $message, $success, $status );
}
private static function request_scalar( array $request, string $key ): string {
if ( ! array_key_exists( $key, $request ) || ! is_scalar( $request[ $key ] ) ) { return ''; }
return (string) wp_unslash( $request[ $key ] );
}
private static function validate_request(): array|WP_Error {
// An emergency link is authorised by its HMAC signature, verified below, not by a WordPress nonce: the person
// using it may not be logged in. Every field is sanitized and format-checked before use.
$request = 'POST' === strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) ? $_POST : $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
$user_id = absint( self::request_scalar( $request, 'uid' ) );
$expires = absint( self::request_scalar( $request, 'expires' ) );
$challenge = sanitize_text_field( self::request_scalar( $request, 'challenge' ) );
$signature = sanitize_text_field( self::request_scalar( $request, 'signature' ) );
if ( $user_id <= 0 || ! preg_match( '/^[a-f0-9]{32}$/', $challenge ) || ! preg_match( '/^[a-f0-9]{64}$/', $signature ) ) {
return new WP_Error( 'nlssl_emergency_invalid', __( 'This emergency link is invalid.', 'neverlast-security-lite' ), 403 );
}
if ( $expires < time() || $expires > time() + self::TOKEN_TTL + MINUTE_IN_SECONDS ) {
return new WP_Error( 'nlssl_emergency_expired', __( 'This emergency link expired. Sign in normally and generate a new administrator-login alert.', 'neverlast-security-lite' ), 410 );
}
$expected = hash_hmac( 'sha256', self::payload( $user_id, $expires, $challenge ), wp_salt( 'auth' ) );
if ( ! hash_equals( $expected, $signature ) ) {
return new WP_Error( 'nlssl_emergency_signature', __( 'This emergency link failed its integrity check.', 'neverlast-security-lite' ), 403 );
}
$stored = get_transient( self::token_key( $challenge ) );
$active = get_transient( 'nlssl_emergency_active_' . $user_id );
if ( ! is_array( $active ) || $challenge !== ( $active['challenge'] ?? '' ) || ! is_array( $stored ) || $user_id !== (int) ( $stored['user_id'] ?? 0 ) || $expires !== (int) ( $stored['expires'] ?? 0 ) ) {
return new WP_Error( 'nlssl_emergency_consumed', __( 'This emergency link has already been used or is no longer available.', 'neverlast-security-lite' ), 410 );
}
$user = get_userdata( $user_id );
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks the plugin's own rate-limit table for an earlier claim of this single-use emergency link; a cached answer would allow reuse.
$claim = $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE rate_key = %s', NLSSL_Core::table( 'rate_limits' ), hash_hmac( 'sha256', 'emergency-claim|' . $challenge, wp_salt( 'nonce' ) ) ) );
if ( ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'emergency_claim_read' ); return new WP_Error( 'nlssl_emergency_storage', __( 'Emergency storage is unavailable. Retry this link after recovery.', 'neverlast-security-lite' ), 503 ); }
if ( null !== $claim ) { return new WP_Error( 'nlssl_emergency_consumed', __( 'This emergency link has already been used.', 'neverlast-security-lite' ), 410 ); }
if ( ! NLSSL_Core::setting( 'emergency_email_enabled', 0 ) || ! self::configured() || ! $user instanceof WP_User || ! user_can( $user, 'manage_options' ) ) {
return new WP_Error( 'nlssl_emergency_revoked', __( 'This emergency link was revoked because the feature or administrator access is no longer active.', 'neverlast-security-lite' ), 403 );
}
return array( 'user_id' => $user_id, 'expires' => $expires, 'challenge' => $challenge, 'signature' => $signature );
}
private static function claim_token( array $state ): bool {
global $wpdb;
$key = hash_hmac( 'sha256', 'emergency-claim|' . $state['challenge'], wp_salt( 'nonce' ) );
$table = NLSSL_Core::table( 'rate_limits' );
// The primary key reserves the link once; capacity exhaustion must not grow or evict live locks.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Claims a single-use emergency link atomically (INSERT IGNORE) in the plugin's own table; this must never be cached.
$inserted = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (rate_key, bucket, hits, expires_at)
SELECT %s, %s, 1, %d FROM DUAL WHERE (SELECT COUNT(*) FROM %i WHERE bucket = %s) < %d', $table, $key, 'emergency_token_claim', (int) $state['expires'], $table, 'emergency_token_claim', NLSSL_Core::MAX_FALLBACK_ROWS ) );
if ( false === $inserted || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'emergency_claim_write' ); }
return 1 === $inserted;
}
private static function render_form( array $state ): void {
self::response_headers( 200 );
$title = __( 'NeverLast Security Lite emergency response', 'neverlast-security-lite' );
echo '
' . esc_html( $title ) . '';
// A standalone page with no wp_head(), so the registered inline style is printed by handle only.
wp_register_style( 'nlssl-emergency', false, array(), NLSSL_VERSION );
wp_add_inline_style( 'nlssl-emergency', 'body{font-family:system-ui,sans-serif;background:#f0f0f1;color:#1d2327;padding:40px}main{max-width:560px;margin:auto;background:#fff;border:1px solid #c3c4c7;padding:28px}label,input,button{display:block;width:100%;box-sizing:border-box}input{margin:8px 0 16px;padding:10px}button{padding:11px;background:#135e96;color:#fff;border:0;font-weight:600}' );
wp_print_styles( 'nlssl-emergency' );
echo '' . esc_html( $title ) . '
';
echo '' . esc_html__( 'Enter the separate emergency secret to invalidate every WordPress session and temporarily freeze new logins. Opening this page alone performs no action.', 'neverlast-security-lite' ) . '
';
echo '';
exit;
}
private static function render( string $message, bool $success, int $status ): void {
self::response_headers( $status );
echo '' . esc_html__( 'NeverLast Security Lite emergency response', 'neverlast-security-lite' ) . '' . esc_html( $success ? __( 'Emergency action completed', 'neverlast-security-lite' ) : __( 'Emergency action refused', 'neverlast-security-lite' ) ) . '
' . esc_html( $message ) . '
';
if ( $success ) { echo '' . esc_html__( 'Open WordPress password recovery', 'neverlast-security-lite' ) . '
'; }
echo '';
exit;
}
private static function response_headers( int $status ): void {
status_header( $status );
nocache_headers();
header( 'Content-Type: text/html; charset=utf-8' );
header( 'Referrer-Policy: no-referrer' );
header( "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" );
header( 'X-Robots-Tag: noindex, nofollow, noarchive' );
}
private static function payload( int $user_id, int $expires, string $challenge ): string {
return 'freeze|' . $user_id . '|' . $expires . '|' . $challenge;
}
private static function token_key( string $challenge ): string {
return 'nlssl_emergency_' . substr( hash( 'sha256', $challenge ), 0, 32 );
}
}