<?php
/**
 * RFC 6238 TOTP — pure PHP, no external dependencies.
 *
 * 6-digit codes, SHA-1, 30s period. Compatible with Google Authenticator,
 * Authy, Microsoft Authenticator, 2FAS, FreeOTP, Aegis and every other
 * standard TOTP app.
 *
 * @package Brainwerk Security Suite
 * @link    https://datatracker.ietf.org/doc/html/rfc6238
 */

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

class Shieldforge_TOTP {

    const DIGITS = 6;
    const PERIOD = 30;
    /** Drift window: how many 30s steps on either side of "now" we accept. */
    const DRIFT  = 1;

    /** Standard base32 alphabet (RFC 4648). */
    const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    /**
     * Generate a new random secret. 20 bytes = 160 bits = recommended size
     * for TOTP. Returns base32-encoded, no padding (so the QR-string stays
     * short and authenticator apps render the manual-entry form cleanly).
     */
    public static function generate_secret( $bytes = 20 ) {
        $raw = function_exists( 'random_bytes' )
            ? random_bytes( (int) $bytes )
            : openssl_random_pseudo_bytes( (int) $bytes );
        return rtrim( self::base32_encode( $raw ), '=' );
    }

    /**
     * Build the otpauth:// URI you put inside the QR code.
     *
     * @param string $secret_b32 base32 secret
     * @param string $account    user-facing account label (usually email or login@host)
     * @param string $issuer     site name shown in the authenticator app
     */
    public static function provisioning_uri( $secret_b32, $account, $issuer ) {
        $issuer_enc  = rawurlencode( $issuer );
        $account_enc = rawurlencode( $account );
        $secret_enc  = rawurlencode( strtoupper( $secret_b32 ) );

        // Convention: label is "Issuer:Account" so the authenticator groups
        // entries by site. The issuer query-string is mandatory for some
        // apps (Authy) to display the icon.
        return sprintf(
            'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d',
            $issuer_enc,
            $account_enc,
            $secret_enc,
            $issuer_enc,
            self::DIGITS,
            self::PERIOD
        );
    }

    /**
     * Compute the TOTP code for a given timestamp + base32 secret.
     */
    public static function code_for( $secret_b32, $timestamp = null ) {
        $timestamp = null === $timestamp ? time() : (int) $timestamp;
        $counter   = (int) floor( $timestamp / self::PERIOD );
        return self::hotp( $secret_b32, $counter );
    }

    /**
     * Verify a user-supplied code against the secret. Returns true if the
     * code matches "now" or any timestep inside the DRIFT window.
     *
     * Constant-time string compare via hash_equals so a timing attack can't
     * leak which timestep was used.
     */
    public static function verify( $secret_b32, $user_code, $timestamp = null ) {
        return false !== self::verify_step( $secret_b32, $user_code, $timestamp );
    }

    /**
     * Like verify(), but returns the matched timestep (counter) as an int on
     * success, or false on failure. The caller can persist the last accepted
     * step and reject any code from that step or earlier — RFC 6238 §5.2
     * requires a previously accepted code to be rejected (replay protection).
     *
     * Walks the full DRIFT window even after a match so the work done is
     * independent of which step matched (timing-attack hygiene).
     *
     * @return int|false matched timestep, or false
     */
    public static function verify_step( $secret_b32, $user_code, $timestamp = null ) {
        $user_code = preg_replace( '/\D+/', '', (string) $user_code );
        if ( strlen( $user_code ) !== self::DIGITS ) {
            return false;
        }
        $timestamp = null === $timestamp ? time() : (int) $timestamp;
        $counter   = (int) floor( $timestamp / self::PERIOD );

        $matched = false;
        for ( $i = -self::DRIFT; $i <= self::DRIFT; $i++ ) {
            $candidate = self::hotp( $secret_b32, $counter + $i );
            if ( hash_equals( $candidate, $user_code ) ) {
                $matched = $counter + $i;
            }
        }
        return $matched;
    }

    /* ------------------------------------------------------------------
     * RFC 4226 HOTP  (TOTP is HOTP with counter = floor(time/period))
     * ------------------------------------------------------------------ */

    public static function hotp( $secret_b32, $counter ) {
        $key = self::base32_decode( $secret_b32 );
        if ( '' === $key ) {
            return str_pad( '', self::DIGITS, '0' );
        }
        // 8-byte big-endian counter. pack('N*', high32, low32) gives us a
        // 64-bit big-endian value without depending on 'J' (which is 64-bit
        // PHP only — true on every supported WP host but defensive coding
        // costs us nothing).
        $bin = pack( 'N*', ( $counter >> 32 ) & 0xFFFFFFFF, $counter & 0xFFFFFFFF );

        $hmac   = hash_hmac( 'sha1', $bin, $key, true );
        $offset = ord( $hmac[ strlen( $hmac ) - 1 ] ) & 0x0F;
        $binary = ( ( ord( $hmac[ $offset ] ) & 0x7F ) << 24 )
                | ( ( ord( $hmac[ $offset + 1 ] ) & 0xFF ) << 16 )
                | ( ( ord( $hmac[ $offset + 2 ] ) & 0xFF ) << 8 )
                | ( ord( $hmac[ $offset + 3 ] ) & 0xFF );

        $code = $binary % ( 10 ** self::DIGITS );
        return str_pad( (string) $code, self::DIGITS, '0', STR_PAD_LEFT );
    }

    /* ------------------------------------------------------------------
     * Base32 (RFC 4648)
     * ------------------------------------------------------------------ */

    public static function base32_encode( $raw ) {
        if ( '' === $raw ) {
            return '';
        }
        $bin = '';
        $len = strlen( $raw );
        for ( $i = 0; $i < $len; $i++ ) {
            $bin .= str_pad( decbin( ord( $raw[ $i ] ) ), 8, '0', STR_PAD_LEFT );
        }
        $padded_len = (int) ceil( strlen( $bin ) / 5 ) * 5;
        $bin = str_pad( $bin, $padded_len, '0', STR_PAD_RIGHT );
        $out = '';
        for ( $i = 0; $i < strlen( $bin ); $i += 5 ) {
            $out .= self::B32_ALPHABET[ bindec( substr( $bin, $i, 5 ) ) ];
        }
        // Pad to multiple of 8 chars.
        $remainder = strlen( $out ) % 8;
        if ( $remainder ) {
            $out .= str_repeat( '=', 8 - $remainder );
        }
        return $out;
    }

    public static function base32_decode( $b32 ) {
        // Uppercase FIRST, then strip: a valid base32 secret typed in lowercase
        // (a-z) must be accepted, not silently deleted by the character filter.
        $b32 = preg_replace( '/[^A-Z2-7]/', '', strtoupper( (string) $b32 ) );
        if ( '' === $b32 ) {
            return '';
        }
        $bin = '';
        $len = strlen( $b32 );
        for ( $i = 0; $i < $len; $i++ ) {
            $pos = strpos( self::B32_ALPHABET, $b32[ $i ] );
            if ( false === $pos ) {
                continue;
            }
            $bin .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
        }
        $out  = '';
        $blen = strlen( $bin ) - ( strlen( $bin ) % 8 );
        for ( $i = 0; $i < $blen; $i += 8 ) {
            $out .= chr( bindec( substr( $bin, $i, 8 ) ) );
        }
        return $out;
    }
}
