<?php
/**
 * HookGet — webhook signature verification for PHP.
 *
 * One file, no dependencies, PHP 7.4+. Copy it into your project; there is
 * deliberately nothing to keep up to date.
 *
 *   require __DIR__ . '/hookget.php';
 *
 *   try {
 *       \HookGet\verify(getenv('HOOKGET_SECRET'), getallheaders(), file_get_contents('php://input'));
 *   } catch (\HookGet\VerificationError $e) {
 *       http_response_code(400);
 *       exit($e->getMessage());
 *   }
 *   http_response_code(200);   // acknowledge first, work afterwards
 *
 * ── THE ONE MISTAKE EVERYONE MAKES ───────────────────────────────────────────
 * Verify the RAW BODY — `file_get_contents('php://input')`, the exact bytes
 * that arrived. Not `json_encode(json_decode($raw))`, which re-serialises and
 * will differ from what was signed the first time a payload contains a
 * non-ASCII character (PHP escapes them as \uXXXX by default), a float that
 * round-trips differently, or a slash it decided to escape. Every "the
 * signature does not match and I cannot see why" ends here.
 *
 * @see https://www.standardwebhooks.com
 * @license Apache-2.0
 */

declare(strict_types=1);

namespace HookGet;

const SECRET_PREFIX = 'whsec_';
const DEFAULT_TOLERANCE_SECONDS = 300;

/** Thrown for every failure, carrying a reason you can branch on. */
class VerificationError extends \Exception
{
    /** @var string */
    public $reason;

    public function __construct(string $reason, string $message)
    {
        parent::__construct($message);
        $this->reason = $reason;
    }
}

/**
 * The secret is base64 and the HMAC key is its BYTES, not its text.
 * Hashing the base64 string itself is the second most common bug here, and it
 * produces a signature that is stable, plausible, and wrong.
 */
function secret_to_key(string $secret): string
{
    $body = strpos($secret, SECRET_PREFIX) === 0
        ? substr($secret, strlen(SECRET_PREFIX))
        : $secret;
    $decoded = base64_decode($body, true);
    if ($decoded === false) {
        throw new VerificationError('bad_secret', 'the signing secret is not valid base64');
    }
    return $decoded;
}

/** The signed content is always `{id}.{timestamp}.{body}`. */
function sign(string $secret, string $id, int $timestamp, string $body): string
{
    $signed = $id . '.' . $timestamp . '.' . $body;
    return base64_encode(hash_hmac('sha256', $signed, secret_to_key($secret), true));
}

/**
 * Reads one header, case-insensitively.
 *
 * `getallheaders()` preserves whatever case the client sent, and PHP-FPM hands
 * you `HTTP_WEBHOOK_ID` instead. Both shapes are accepted, because the
 * alternative is a library that works on Apache and silently fails on nginx.
 */
function header_value(array $headers, string $name): ?string
{
    $wanted = strtolower($name);
    $cgi = 'http_' . str_replace('-', '_', $wanted);
    foreach ($headers as $key => $value) {
        $lower = strtolower((string) $key);
        if ($lower === $wanted || $lower === $cgi) {
            return is_array($value) ? (string) reset($value) : (string) $value;
        }
    }
    return null;
}

/**
 * Verifies a delivery, or throws.
 *
 * @param string|string[] $secrets One secret, or several during rotation.
 * @return array{id: string, timestamp: int}
 */
function verify(
    $secrets,
    array $headers,
    string $body,
    int $toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
    ?int $now = null
): array {
    $list = is_array($secrets) ? $secrets : [$secrets];

    $id = header_value($headers, 'webhook-id');
    $rawTimestamp = header_value($headers, 'webhook-timestamp');
    $signatureHeader = header_value($headers, 'webhook-signature');
    if ($id === null || $rawTimestamp === null || $signatureHeader === null) {
        throw new VerificationError(
            'missing_headers',
            'missing webhook-id, webhook-timestamp or webhook-signature'
        );
    }

    if (preg_match('/^-?\d+$/', $rawTimestamp) !== 1) {
        throw new VerificationError('bad_timestamp', 'webhook-timestamp is not an integer');
    }
    $timestamp = (int) $rawTimestamp;

    // A replay window. Without it a captured delivery stays valid forever, and
    // an attacker who ever saw one valid request can send it again at will.
    $current = $now ?? time();
    if (abs($current - $timestamp) > $toleranceSeconds) {
        throw new VerificationError(
            'timestamp_out_of_tolerance',
            "timestamp is more than {$toleranceSeconds}s away from now"
        );
    }

    // Space-separated during rotation: both the old and the new secret sign the
    // same body, so a rotation never drops a delivery.
    $presented = array_filter(explode(' ', $signatureHeader), static fn($p) => $p !== '');

    foreach ($list as $secret) {
        $expected = sign((string) $secret, $id, $timestamp, $body);
        foreach ($presented as $candidate) {
            $comma = strpos($candidate, ',');
            if ($comma === false || substr($candidate, 0, $comma) !== 'v1') {
                continue;
            }
            $value = substr($candidate, $comma + 1);
            // hash_equals and not ===: a comparison that returns early leaks
            // the signature one byte at a time to anyone willing to measure.
            if (hash_equals($expected, $value)) {
                return ['id' => $id, 'timestamp' => $timestamp];
            }
        }
    }

    throw new VerificationError('no_matching_signature', 'no presented signature matched');
}

/** Non-throwing form, for callers that prefer a boolean. */
function is_valid(
    $secrets,
    array $headers,
    string $body,
    int $toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
    ?int $now = null
): bool {
    try {
        verify($secrets, $headers, $body, $toleranceSeconds, $now);
        return true;
    } catch (VerificationError $e) {
        return false;
    }
}
