Fireblocks V2 signature validation on PHP

I was previously helped by a post here to get Fireblocks signature validation on PHP, so with V2 coming, I had to get it work by myself now, took some time until I got it to work fully,

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

class FireblocksBase {
    public function validateSignature(string $fireblocksSignature, string $jsonString): bool {
        try {

            if (empty($fireblocksSignature)) {
                throw new \Exception('er_invalid_signature');
            }
            [$base64header, $_ignore, $base64signature] = explode('.', $fireblocksSignature);
            if(!$base64header || !$base64signature) {
                throw new \Exception('er_invalid_signature');
            }
            /**
             * @example Header Example:
             * {
             *     "alg": "RS512",
             *     "kid": "webhook-key-2025-01"
             * }
             */
            $header = base64_decode($base64header);
            $header = json_decode($header, true);
            if(!$header || !isset($header['kid'])) {
                throw new \Exception('er_invalid_signature');
            }
            // Here you can return just the full contents of the `.well-known/jwks.json` file
            // Or you can return only the key you need (what I'm doing here)
            $jwks = [ 'keys' => [$this->getWebhookJwks($header['kid'])] ];
            $keys = JWK::parseKeySet($jwks);

            $recreatedFullJson = $base64header.'.'.JWT::urlsafeB64Encode($jsonString).'.'.$base64signature;

            // If the JWT is invalid, it will throw, otherwise, it's a success
            $decoded = JWT::decode($recreatedFullJson, $keys);
        } catch (\Exception $e) {
            throw $e;
        }

        return true;
    }

    protected function getWebhookJwks(string $kid): array {
        $cacheKey = 'fireblocks_jwks:';
        // Use whatever Cache method you already use
        $cachedKeys = Cache::getJson($cacheKey.$kid);
        if($cachedKeys) {
            // Be sure that this is all associative arrays, no stdClass objects
            return (array) $cachedKeys;
        }

        $jwksUrl = getenv('FIREBLOCKS_JWKS_URL').'/.well-known/jwks.json';
        $context = stream_context_create([
            'http' => [
                'timeout' => 5,
            ],
        ]);
        $jwksRaw = file_get_contents($jwksUrl, false, $context);
        if ($jwksRaw === false) {
            throw new \Exception('er_invalid_signature');
        }

        $jwks = json_decode($jwksRaw, true);
        if (!is_array($jwks) || !isset($jwks['keys']) || !isset($jwks['keys'][0]) || !isset($jwks['keys'][0]['kty'])) {
            throw new \Exception('er_invalid_signature');
        }

        // Cache keys individually 1 hour
        $response = null;
        foreach($jwks['keys'] as $key) {
            Cache::putJson($cacheKey.$key['kid'], $key, 3600);
            if($key['kid'] == $kid) {
                $response = $key;
            }
        }
        if(!$response) {
            throw new \Exception('er_invalid_signature');
        }
        return $response;
    }
}

For Caching you do your own solution, a reminder that you can just return the contents of /.well-known/jwks.json as they are.

In my case I cache and search for the individual keys, and have to recreate the { keys: [keydata]} structure.