<?php
/**
 * HaveIBeenPwned password breach check.
 *
 * Prüft Passwörter beim Setzen (Registration, Profile-Update, Password-Reset)
 * gegen die HIBP-Breach-DB (~12 Mrd. geleakte Passwörter).
 *
 * Privacy-First via k-Anonymity:
 * - Nur die ersten 5 Zeichen des SHA1-Hashes werden an api.pwnedpasswords.com gesendet
 * - HIBP retourniert eine Liste aller Suffixe für dieses Präfix (~500-800 Hashes)
 * - Vergleich passiert lokal — HIBP sieht das echte Passwort nie
 *
 * Quelle: https://haveibeenpwned.com/API/v3#PwnedPasswords
 *
 * @package Brainwerk Security Suite
 */

if ( ! defined( 'ABSPATH' ) ) exit;

class Shieldforge_Pwned {

    const API_URL = 'https://api.pwnedpasswords.com/range/';
    const CACHE_TTL = DAY_IN_SECONDS;

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! Shieldforge_Settings::get( 'pwned_check_enabled', false ) ) return;
        // Verhindert das Setzen kompromittierter Passwörter beim Registrieren + Profil-Update
        $loader->add_action( 'user_profile_update_errors', __CLASS__, 'check_profile', 10, 3 );
        $loader->add_action( 'registration_errors',        __CLASS__, 'check_register', 10, 3 );
        $loader->add_action( 'validate_password_reset',    __CLASS__, 'check_reset', 10, 2 );
    }

    /** Profile-Update: vom User selbst oder Admin geändertes Passwort. */
    public static function check_profile( &$errors, $update, &$user ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce ('update-user_{ID}') already verified by WP core (check_admin_referer in wp-admin/user-edit.php) before this hook fires; read-only password strength check, does not itself persist anything.
        if ( empty( $_POST['pass1'] ) ) return;
        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must reach lookup() byte-for-byte for a correct SHA1 hash (sanitize_text_field would corrupt it); nonce already verified by WP core (see above), and value is never echoed or stored by this class. wp_unslash() only removes WP's magic-quotes escaping, it does not alter the password content.
        $pw = (string) wp_unslash( $_POST['pass1'] );
        if ( '' === $pw ) return;
        $count = self::lookup( $pw );
        if ( $count > 0 ) {
            $errors->add( 'pwned_password',
                sprintf(
                    /* translators: %s: number of times leaked */
                    __( '<strong>Sicherheitswarnung:</strong> Dieses Passwort taucht <strong>%s mal</strong> in bekannten Daten-Leaks auf (Quelle: HaveIBeenPwned). Bitte wählen Sie ein anderes Passwort.', 'brainwerk-security-suite' ),
                    number_format_i18n( $count )
                )
            );
        }
    }

    /** Registrierungs-Formular. */
    public static function check_register( $errors, $sanitized_user_login, $user_email ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the default WP registration form (wp-login.php?action=register) sends no nonce; this hook only reads the submitted password to validate its strength, it does not itself persist anything (WP core creates the account only if no errors were added).
        if ( empty( $_POST['user_pass'] ) && empty( $_POST['pass1'] ) ) return $errors;
        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must reach lookup() byte-for-byte for a correct SHA1 hash (sanitize_text_field would corrupt it); see justification above. wp_unslash() only removes WP's magic-quotes escaping.
        $pw = (string) wp_unslash( $_POST['pass1'] ?? $_POST['user_pass'] ?? '' );
        if ( '' === $pw ) return $errors;
        $count = self::lookup( $pw );
        if ( $count > 0 ) {
            $errors->add( 'pwned_password',
                sprintf( __( 'Dieses Passwort taucht %s mal in bekannten Daten-Leaks auf. Bitte ein anderes wählen.', 'brainwerk-security-suite' ), number_format_i18n( $count ) )
            );
        }
        return $errors;
    }

    /** Password-Reset-Formular. */
    public static function check_reset( $errors, $user ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this hook fires from wp-login.php's reset_password() flow, which is already gated by the single-use rp_key/rp_login reset token validated by core before this action runs; read-only strength check, does not itself persist the password.
        if ( empty( $_POST['pass1'] ) ) return;
        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must reach lookup() byte-for-byte for a correct SHA1 hash (sanitize_text_field would corrupt it); nonce justification as above. wp_unslash() only removes WP's magic-quotes escaping.
        $pw = (string) wp_unslash( $_POST['pass1'] );
        $count = self::lookup( $pw );
        if ( $count > 0 ) {
            $errors->add( 'pwned_password',
                sprintf( __( 'Dieses Passwort wurde %s mal geleakt. Bitte ein anderes wählen.', 'brainwerk-security-suite' ), number_format_i18n( $count ) )
            );
        }
    }

    /**
     * Liefert wie oft das Passwort in HIBP-Datenbank vorkommt (0 = sicher).
     * k-Anonymity: nur 5-Zeichen-Präfix wird gesendet — HIBP sieht NIE das Passwort.
     */
    public static function lookup( $password ) {
        $hash = strtoupper( sha1( $password ) );
        $prefix = substr( $hash, 0, 5 );
        $suffix = substr( $hash, 5 );

        $cache_key = 'shieldforge_pwned_' . $prefix;
        $cached = get_transient( $cache_key );
        if ( false === $cached ) {
            $resp = wp_remote_get( self::API_URL . $prefix, array(
                'timeout' => 6,
                'headers' => array( 'Add-Padding' => 'true', 'User-Agent' => 'Brainwerk Security Suite/' . SHIELDFORGE_VERSION ),
            ) );
            if ( is_wp_error( $resp ) || (int) wp_remote_retrieve_response_code( $resp ) !== 200 ) {
                // Fail-open: bei Netzwerkfehler nicht blockieren, sondern Pass-Durch erlauben
                return 0;
            }
            $cached = (string) wp_remote_retrieve_body( $resp );
            set_transient( $cache_key, $cached, self::CACHE_TTL );
        }

        foreach ( preg_split( "/\r?\n/", $cached ) as $line ) {
            $parts = explode( ':', trim( $line ), 2 );
            if ( count( $parts ) === 2 && strcasecmp( $parts[0], $suffix ) === 0 ) {
                return (int) $parts[1];
            }
        }
        return 0;
    }
}
