CSRF.php

PHP

Path: src/CSRF.php

<?php

namespace mini;

/**
 * WordPress-inspired nonce (CSRF) token system
 *
 * Tokens are self-contained with action, timestamp, and IP address,
 * signed with HMAC using session ID, user agent, and application salt.
 *
 * Usage:
 *   $nonce = new CSRF('delete-post');
 *   render('form.php', ['nonce' => $nonce]);
 *
 *   // In template:
 *   <form method="post">
 *     <?= $nonce ?>
 *     ...
 *   </form>
 *
 *   // Verify:
 *   $nonce = new CSRF('delete-post');
 *   if ($nonce->verify($_POST['__nonce__'])) {
 *     // Process form
 *   }
 */
class CSRF
{
    private string $action;
    private string $fieldName;
    private ?string $token = null;

    /**
     * Create a CSRF token for a specific action
     *
     * @param string $action Action name (e.g., 'delete-post', 'update-settings')
     * @param string $fieldName HTML field name (default: '__nonce__')
     */
    public function __construct(string $action, string $fieldName = '__nonce__')
    {
        $this->action = $action;
        $this->fieldName = $fieldName;
    }

    /**
     * Build signature key from hard-to-guess components
     *
     * Includes application salt, session ID, and user agent to make
     * tokens difficult to forge even if attacker knows the action.
     */
    private function buildSignatureKey(): string
    {
        $hardToGuess = Mini::$mini->salt;

        // Include session ID if available
        $sessionName = session_name() ?: '';
        if ($sessionName && isset($_COOKIE[$sessionName])) {
            $hardToGuess .= $_COOKIE[$sessionName];
        }

        // Include user agent for browser fingerprinting
        $hardToGuess .= $_SERVER['HTTP_USER_AGENT'] ?? '';

        return $hardToGuess;
    }

    /**
     * Generate a new token with current timestamp and IP
     */
    private function generateToken(): string
    {
        $data = implode('|', [
            $this->action,
            (string) microtime(true),
            $_SERVER['REMOTE_ADDR'] ?? ''
        ]);

        $signature = hash_hmac('sha256', $data, $this->buildSignatureKey());
        $token = $data . '|' . $signature;

        return base64_encode($token);
    }

    /**
     * Get the token string (lazy generation)
     */
    public function getToken(): string
    {
        if ($this->token === null) {
            $this->token = $this->generateToken();
        }
        return $this->token;
    }

    /**
     * Verify a token
     *
     * @param string|null $token Token to verify (typically from $_POST)
     * @param float $maxAge Maximum age in seconds (default: 86400 = 24 hours)
     * @return bool True if valid and not expired
     */
    public function verify(?string $token, float $maxAge = 86400): bool
    {
        if ($token === null || $token === '') {
            return false;
        }

        // Decode token
        $decoded = base64_decode($token, true);
        if ($decoded === false) {
            return false;
        }

        // Split into parts: action|time|ip|signature
        $parts = explode('|', $decoded);
        if (count($parts) !== 4) {
            return false;
        }

        [$action, $time, $ip, $signature] = $parts;

        // Verify action matches
        if ($action !== $this->action) {
            return false;
        }

        // Verify not expired
        $age = microtime(true) - (float) $time;
        if ($age > $maxAge || $age < 0) {
            return false;
        }

        // Verify IP matches (if IP was recorded)
        $currentIp = $_SERVER['REMOTE_ADDR'] ?? '';
        if ($ip !== '' && $ip !== $currentIp) {
            return false;
        }

        // Verify signature using same key derivation
        $data = implode('|', [$action, $time, $ip]);
        $expectedSignature = hash_hmac('sha256', $data, $this->buildSignatureKey());

        return hash_equals($expectedSignature, $signature);
    }

    /**
     * Output hidden input field
     */
    public function __toString(): string
    {
        return sprintf(
            '<input type="hidden" name="%s" value="%s">',
            htmlspecialchars($this->fieldName, ENT_QUOTES, 'UTF-8'),
            htmlspecialchars($this->getToken(), ENT_QUOTES, 'UTF-8')
        );
    }
}