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.