Validating webhooks

Method 2: Signature Verification

Fireblocks cryptographically signs every webhook, allowing you to verify authenticity and integrity.

Signature Methods

MethodHeaderKey RotationStatus
JWKSFireblocks-Webhook-Signature✅ AutomaticRecommended
LegacyFireblocks-Signature❌ ManualDeprecated Jan 30

During migration, both headers are sent with each webhook.

Validating webhooks (JWKS)

How It Works

The new signature uses Detached JWS (JSON Web Signature) format:

Fireblocks-Webhook-Signature: "[header]..[signature]"

The kid (key ID) is embedded in the JWS header, allowing automatic key lookup from the JWKS endpoint. The payload is sent separately in the request body (detached format).

JWKS Endpoints

Fireblocks publishes public keys in JWKS format for validating webhook signatures. Use the endpoint that matches your workspace's environment.

Response format:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "webhook-key-2025-01",
      "use": "sig",
      "alg": "RS512",
      "n": "0vx7agoebGcQemDqTnUt...",
      "e": "AQAB"
    }
  ]
}
FieldDescription
ktyKey type. Always RSA for Fireblocks webhook keys.
kidKey ID. Matches the kid header in incoming webhook JWTs.
useKey usage. Always sig (signature verification).
algAlgorithm. Fireblocks uses RS512 (RSA with SHA-512).
nRSA modulus (Base64URL-encoded).
eRSA exponent (Base64URL-encoded).

Caching Behavior

The JWKS endpoint returns caching headers to optimize performance:

Cache-Control: public, max-age=3600, s-maxage=3600 Access-Control-Allow-Origin: * Content-Type: application/json

Code Examples (JWKS)

import { createRemoteJWKSet, compactVerify } from 'jose';

// Initialize JWKS client (caches keys automatically)
const JWKS = createRemoteJWKSet(
  new URL('https://keys.fireblocks.io/.well-known/jwks.json')
);

async function verifyWebhookJWKS(
  rawBody: Buffer,
  jwsSignature: string
): Promise<boolean> {
  try {
    // Detached JWS format: "header..signature" (no payload)
    const [header, , sig] = jwsSignature.split('.');
    
    // Reconstruct full JWS with payload
    const payload = Buffer.from(rawBody).toString('base64url');
    const fullJws = `${header}.${payload}.${sig}`;
    
    // jose extracts kid from header and fetches correct key from JWKS
    await compactVerify(fullJws, JWKS);
    return true;
  } catch (error) {
    console.error('JWKS verification failed:', error);
    return false;
  }
}

// Usage: pass raw body and Fireblocks-Webhook-Signature header
const isValid = await verifyWebhookJWKS(rawBody, jwsSignatureHeader);
import base64
from jwcrypto import jwk, jws
from jwcrypto.common import json_decode
import requests

class FireblocksJWKSVerifier:
    def __init__(self, jwks_url: str = "https://api.fireblocks.io/.well-known/jwks.json"):
        self.jwks_url = jwks_url
        self._jwks = None
    
    def _fetch_jwks(self) -> jwk.JWKSet:
        """Fetch and cache JWKS from endpoint."""
        if self._jwks is None:
            response = requests.get(self.jwks_url)
            response.raise_for_status()
            self._jwks = jwk.JWKSet.from_json(response.text)
        return self._jwks
    
    def verify(self, raw_body: bytes, jws_signature: str) -> bool:
        """
        Verify Fireblocks webhook using JWKS.
        
        Args:
            raw_body: The raw request body as bytes
            jws_signature: The detached JWS from Fireblocks-Webhook-Signature header
        """
        try:
            # Detached JWS: "header..signature"
            parts = jws_signature.split('.')
            header, sig = parts[0], parts[2]
            
            # Reconstruct with base64url-encoded payload
            payload = base64.urlsafe_b64encode(raw_body).rstrip(b'=').decode()
            full_jws = f"{header}.{payload}.{sig}"
            
            # Verify using JWKS (kid extracted automatically from header)
            jwks = self._fetch_jwks()
            jws_obj = jws.JWS()
            jws_obj.deserialize(full_jws)
            jws_obj.verify(jwks)
            return True
        except Exception as e:
            print(f"JWKS verification failed: {e}")
            return False


# Usage
verifier = FireblocksJWKSVerifier()
is_valid = verifier.verify(raw_body, jws_signature_header)
package main

import (
	"context"
	"encoding/base64"
	"log"
	"strings"

	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/lestrrat-go/jwx/v2/jws"
)

type JWKSVerifier struct {
	cache *jwk.Cache
	url   string
}

func NewJWKSVerifier(jwksURL string) (*JWKSVerifier, error) {
	cache := jwk.NewCache(context.Background())
	err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(time.Hour))
	if err != nil {
		return nil, err
	}
	return &JWKSVerifier{cache: cache, url: jwksURL}, nil
}

// VerifyWebhookJWKS verifies Fireblocks webhook using JWKS.
// rawBody: The raw request body
// jwsSignature: The detached JWS from Fireblocks-Webhook-Signature header
func (v *JWKSVerifier) VerifyWebhookJWKS(rawBody []byte, jwsSignature string) bool {
	// Detached JWS: "header..signature"
	parts := strings.Split(jwsSignature, ".")
	if len(parts) != 3 {
		log.Println("Invalid JWS format")
		return false
	}
	header, sig := parts[0], parts[2]

	// Reconstruct with base64url-encoded payload
	payload := base64.RawURLEncoding.EncodeToString(rawBody)
	fullJws := header + "." + payload + "." + sig

	// Fetch keys and verify
	keySet, err := v.cache.Get(context.Background(), v.url)
	if err != nil {
		log.Printf("Failed to fetch JWKS: %v", err)
		return false
	}

	_, err = jws.Verify([]byte(fullJws), jws.WithKeySet(keySet))
	if err != nil {
		log.Printf("JWKS verification failed: %v", err)
		return false
	}
	return true
}

// Usage:
// verifier, _ := NewJWKSVerifier("https://api.fireblocks.io/.well-known/jwks.json")
// isValid := verifier.VerifyWebhookJWKS(rawBody, jwsSignatureHeader)
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import java.net.URL;
import java.util.Base64;
import java.util.concurrent.TimeUnit;

public class FireblocksJWKSVerifier {
    
    private final JWKSource<SecurityContext> jwkSource;
    
    public FireblocksJWKSVerifier(String jwksUrl) throws Exception {
        // Cache JWKS for 1 hour, refresh 5 min before expiry
        var cache = new DefaultJWKSetCache(1, 5, TimeUnit.HOURS);
        this.jwkSource = new RemoteJWKSet<>(new URL(jwksUrl), null, cache);
    }
    
    public FireblocksJWKSVerifier() throws Exception {
        this("https://keys.fireblocks.io/.well-known/jwks.json");
    }
    
    public boolean verify(byte[] rawBody, String jwsSignature) throws JOSEException {
        // Reconstruct full JWS from detached format (header..signature)
        String[] parts = jwsSignature.split("\\.");
        String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(rawBody);
        String fullJws = parts[0] + "." + payload + "." + parts[2];
        
        JWSObject jws = JWSObject.parse(fullJws);
        
        // Get the key by kid from JWKS (cached)
        JWKSelector selector = new JWKSelector(
            new JWKMatcher.Builder().keyID(jws.getHeader().getKeyID()).build()
        );
        RSAKey rsaKey = (RSAKey) jwkSource.get(selector, null).get(0);
        
        return jws.verify(new RSASSAVerifier(rsaKey));
    }
}

JWKS Caching Best Practices

Cache the JWKS: Most libraries handle this automatically. Respect Cache-Control: JWKS responses include max-age=3600 (1 hour). Refresh accordingly. Handle rotation gracefully: Multiple keys will be present, the kid in the JWS header identifies which key to use.

Common Pitfalls

IssueSolution
Body parsing before verificationUse raw body parser middleware. JSON parsing alters whitespace/ordering.
Wrong JWKS URLUse the correct endpoint for your environment
Stale JWKS cacheRefresh cache if verification fails with unknown kid
Encoding issuesEnsure body is read as raw bytes, not string-converted

Legacy Static Key Validation

⚠️ Migration Notice: This method is being replaced by JWKS-based validation. Both headers are sent until January 30. New integrations should use JWKS.

How the Signature Works

Fireblocks signs every webhook event with their private key. The signature is sent in the Fireblocks-Signature HTTP header:

Fireblocks-Signature: Base64(RSA512(WEBHOOK_PRIVATE_KEY, SHA512(eventBody)))

Breakdown:

SHA512(eventBody) — Hash the raw request body with SHA-512
RSA512 Sign — Fireblocks signs the hash with their private RSA key (PKCS#1 v1.5)
Base64 Encode — The signature is base64-encoded for transport

Your job: Verify the signature using Fireblocks' public key to confirm authenticity.

Public Keys by Environment

US Mainnet & Testnet

-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0+6wd9OJQpK60ZI7qnZG
jjQ0wNFUHfRv85Tdyek8+ahlg1Ph8uhwl4N6DZw5LwLXhNjzAbQ8LGPxt36RUZl5
YlxTru0jZNKx5lslR+H4i936A4pKBjgiMmSkVwXD9HcfKHTp70GQ812+J0Fvti/v
4nrrUpc011Wo4F6omt1QcYsi4GTI5OsEbeKQ24BtUd6Z1Nm/EP7PfPxeb4CP8KOH
clM8K7OwBUfWrip8Ptljjz9BNOZUF94iyjJ/BIzGJjyCntho64ehpUYP8UJykLVd
CGcu7sVYWnknf1ZGLuqqZQt4qt7cUUhFGielssZP9N9x7wzaAIFcT3yQ+ELDu1SZ
dE4lZsf2uMyfj58V8GDOLLE233+LRsRbJ083x+e2mW5BdAGtGgQBusFfnmv5Bxqd
HgS55hsna5725/44tvxll261TgQvjGrTxwe7e5Ia3d2Syc+e89mXQaI/+cZnylNP
SwCCvx8mOM847T0XkVRX3ZrwXtHIA25uKsPJzUtksDnAowB91j7RJkjXxJcz3Vh1
4k182UFOTPRW9jzdWNSyWQGl/vpe9oQ4c2Ly15+/toBo4YXJeDdDnZ5c/O+KKadc
IMPBpnPrH/0O97uMPuED+nI6ISGOTMLZo35xJ96gPBwyG5s2QxIkKPXIrhgcgUnk
tSM7QYNhlftT4/yVvYnk0YcCAwEAAQ==
-----END PUBLIC KEY-----

EU & EU2 Mainnet & Testnet

-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA6hLRQL0jPf5OEuaDYGjO
xSyaYIlv08S0+4giiwgKSfV3Onc5hn03mvE0znzaUq2ReSxi9KYDdMYFfzf1uwF7
7kYy2MY0oTYGdQb+PS4Ym4R4tgZ2otuoAXt8YRKq2maWyguFiaowMcYwwAVQv8JB
afIm6Jq1nI6v1mEDVX065ePlBlAt+BGAqr6ahPxnaIz3L4eztpuNrt5nTbSxs7eF
aqQx1p56W1nl3Hl0V3tLkaXbuVtbFNR/mGMInrkPnpsG+mt35b9vmqAOvLPI0Cx1
59uVeEs62Hj1AOCRyT6SuwIaFynRj2KnD42ioQtkodHQ0xDtgdiYGsxuwQ9vTIe7
5oLsL8gBDeX5gdcTfSZhfGjZ7RggLNJ7vCAbYKMuUOdgWVMYnJfrhNLCq3zDSZPO
+H0x5m/Yeq/Hn5o7xCmLNT3qARfwDd5IHfQyXqVYB6TMU75xqH5fdSRw0iMdoPyL
ALnr9/JT0av3qssNMRdWCXr+j9Ys3NkfcbU/a49657mg8e2QGSkl9w39csEKojnr
omUz25szIL8CcXLmc5cAmnimFCe4L7UT4mvVP3+fOo+cbc/82zqA8tsSwd2Y93/6
ueGnNZD9V5rewrKjmdPfrwoI2gntzc8QJUu+nxAWhoqHV91AQeglu6WIF/DiEJC5
WPoNk2SdlAuA6RYmgB2YyikCAwEAAQ==
-----END PUBLIC KEY-----

Developer Sandbox

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZE6wL2+7P1ohvVYSpCd
gSgtmyGwiLbUC1UoGJhn1zwfY7ZWbNH7Pg8Osk8OzZTZHSG/arcgE8HnGCmGKtbE
QBkf2XlBRBQ01FcCMlZuJQJ3nElCPaMl9N6fq0VKNEIlVSVUpDCgvag5kFhDKS/L
p3YYJLFR46/hDlVLn+vM84diO3xGyMc16YJGNz7Z4jb8dmSZQE5E2XaQMDXW6uxC
c2ChjWJ3X5H70MzRG35JsN0j58SQTwbf4Pxm0aJfhPuaIBn3mJuZL5etsuFihoFG
FDnT+qWRcgD/pRNulBFAFhJeUnFrE4fFTJ1iaHhjBrStBCrxJk6QI0pGznoapTgA
2QIDAQAB
-----END PUBLIC KEY-----

Step-by-Step Validation Process

Extract the signature from the Fireblocks-Signature header Base64 decode the signature to get raw bytes Read the raw request body (do NOT parse or modify it) Hash the body with SHA-512 Verify using RSA PKCS#1 v1.5 with the Fireblocks public key Accept or reject the webhook based on verification result

Code Examples

import crypto from 'crypto';

// Select the appropriate key for your environment
const FIREBLOCKS_PUBLIC_KEY_US = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0+6wd9OJQpK60ZI7qnZG
jjQ0wNFUHfRv85Tdyek8+ahlg1Ph8uhwl4N6DZw5LwLXhNjzAbQ8LGPxt36RUZl5
YlxTru0jZNKx5lslR+H4i936A4pKBjgiMmSkVwXD9HcfKHTp70GQ812+J0Fvti/v
4nrrUpc011Wo4F6omt1QcYsi4GTI5OsEbeKQ24BtUd6Z1Nm/EP7PfPxeb4CP8KOH
clM8K7OwBUfWrip8Ptljjz9BNOZUF94iyjJ/BIzGJjyCntho64ehpUYP8UJykLVd
CGcu7sVYWnknf1ZGLuqqZQt4qt7cUUhFGielssZP9N9x7wzaAIFcT3yQ+ELDu1SZ
dE4lZsf2uMyfj58V8GDOLLE233+LRsRbJ083x+e2mW5BdAGtGgQBusFfnmv5Bxqd
HgS55hsna5725/44tvxll261TgQvjGrTxwe7e5Ia3d2Syc+e89mXQaI/+cZnylNP
SwCCvx8mOM847T0XkVRX3ZrwXtHIA25uKsPJzUtksDnAowB91j7RJkjXxJcz3Vh1
4k182UFOTPRW9jzdWNSyWQGl/vpe9oQ4c2Ly15+/toBo4YXJeDdDnZ5c/O+KKadc
IMPBpnPrH/0O97uMPuED+nI6ISGOTMLZo35xJ96gPBwyG5s2QxIkKPXIrhgcgUnk
tSM7QYNhlftT4/yVvYnk0YcCAwEAAQ==
-----END PUBLIC KEY-----`;

function verifyWebhookSignature(
  rawBody: Buffer,
  signature: string,
  publicKey: string = FIREBLOCKS_PUBLIC_KEY_US
): boolean {
  try {
    const verifier = crypto.createVerify('RSA-SHA512');
    verifier.update(rawBody);
    return verifier.verify(publicKey, signature, 'base64');
  } catch (error) {
    console.error('Signature verification failed:', error);
    return false;
  }
}

/**
 * IMPORTANT:
 * This function requires the **raw request body**.
 * Do NOT use express.json() or any body parser that mutates the payload.
 *
 * Use:
 *   app.use(express.raw({ type: 'application/json' }))
 *
 * Otherwise, signature verification will fail.
 */
// Usage: pass raw request body (Buffer) and Fireblocks-Signature header value const isValid = verifyWebhookSignature(rawBody, signatureHeader);
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

# Select the appropriate key for your environment
FIREBLOCKS_PUBLIC_KEY_US = """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0+6wd9OJQpK60ZI7qnZG
jjQ0wNFUHfRv85Tdyek8+ahlg1Ph8uhwl4N6DZw5LwLXhNjzAbQ8LGPxt36RUZl5
YlxTru0jZNKx5lslR+H4i936A4pKBjgiMmSkVwXD9HcfKHTp70GQ812+J0Fvti/v
4nrrUpc011Wo4F6omt1QcYsi4GTI5OsEbeKQ24BtUd6Z1Nm/EP7PfPxeb4CP8KOH
clM8K7OwBUfWrip8Ptljjz9BNOZUF94iyjJ/BIzGJjyCntho64ehpUYP8UJykLVd
CGcu7sVYWnknf1ZGLuqqZQt4qt7cUUhFGielssZP9N9x7wzaAIFcT3yQ+ELDu1SZ
dE4lZsf2uMyfj58V8GDOLLE233+LRsRbJ083x+e2mW5BdAGtGgQBusFfnmv5Bxqd
HgS55hsna5725/44tvxll261TgQvjGrTxwe7e5Ia3d2Syc+e89mXQaI/+cZnylNP
SwCCvx8mOM847T0XkVRX3ZrwXtHIA25uKsPJzUtksDnAowB91j7RJkjXxJcz3Vh1
4k182UFOTPRW9jzdWNSyWQGl/vpe9oQ4c2Ly15+/toBo4YXJeDdDnZ5c/O+KKadc
IMPBpnPrH/0O97uMPuED+nI6ISGOTMLZo35xJ96gPBwyG5s2QxIkKPXIrhgcgUnk
tSM7QYNhlftT4/yVvYnk0YcCAwEAAQ==
-----END PUBLIC KEY-----"""


def verify_webhook_signature(
    raw_body: bytes,
    signature: str,
    public_key_pem: str = FIREBLOCKS_PUBLIC_KEY_US
) -> bool:
    """
    Verify Fireblocks webhook signature.
    
    Args:
        raw_body: The raw request body as bytes
        signature: The base64-encoded signature from Fireblocks-Signature header
        public_key_pem: The Fireblocks public key in PEM format
    
    Returns:
        True if signature is valid, False otherwise
    """
    try:
        public_key = serialization.load_pem_public_key(
            public_key_pem.encode(),
            backend=default_backend()
        )
        signature_bytes = base64.b64decode(signature)
        
        # Verify: RSA-SHA512 with PKCS#1 v1.5 padding
        public_key.verify(
            signature_bytes,
            raw_body,
            padding.PKCS1v15(),
            hashes.SHA512()
        )
        return True
    except Exception as e:
        print(f"Signature verification failed: {e}")
        return False


# Usage: pass raw request body (bytes) and Fireblocks-Signature header value
is_valid = verify_webhook_signature(raw_body, signature_header)

# IMPORTANT:
# Signature verification requires the **raw request body bytes**.
#
# - Flask: use request.get_data()
# - FastAPI: use await request.body()
#
# Do NOT use request.json() or request.form(),
# as they modify the payload and will invalidate the signature.
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class FireblocksWebhookVerifier {

    // Select the appropriate key for your environment (base64-encoded, no PEM headers)
    private static final String FIREBLOCKS_PUBLIC_KEY_US = 
        "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0+6wd9OJQpK60ZI7qnZG" +
        "jjQ0wNFUHfRv85Tdyek8+ahlg1Ph8uhwl4N6DZw5LwLXhNjzAbQ8LGPxt36RUZl5" +
        "YlxTru0jZNKx5lslR+H4i936A4pKBjgiMmSkVwXD9HcfKHTp70GQ812+J0Fvti/v" +
        "4nrrUpc011Wo4F6omt1QcYsi4GTI5OsEbeKQ24BtUd6Z1Nm/EP7PfPxeb4CP8KOH" +
        "clM8K7OwBUfWrip8Ptljjz9BNOZUF94iyjJ/BIzGJjyCntho64ehpUYP8UJykLVd" +
        "CGcu7sVYWnknf1ZGLuqqZQt4qt7cUUhFGielssZP9N9x7wzaAIFcT3yQ+ELDu1SZ" +
        "dE4lZsf2uMyfj58V8GDOLLE233+LRsRbJ083x+e2mW5BdAGtGgQBusFfnmv5Bxqd" +
        "HgS55hsna5725/44tvxll261TgQvjGrTxwe7e5Ia3d2Syc+e89mXQaI/+cZnylNP" +
        "SwCCvx8mOM847T0XkVRX3ZrwXtHIA25uKsPJzUtksDnAowB91j7RJkjXxJcz3Vh1" +
        "4k182UFOTPRW9jzdWNSyWQGl/vpe9oQ4c2Ly15+/toBo4YXJeDdDnZ5c/O+KKadc" +
        "IMPBpnPrH/0O97uMPuED+nI6ISGOTMLZo35xJ96gPBwyG5s2QxIkKPXIrhgcgUnk" +
        "tSM7QYNhlftT4/yVvYnk0YcCAwEAAQ==";

    private final PublicKey publicKey;

    public FireblocksWebhookVerifier() throws Exception {
        this.publicKey = loadPublicKey(FIREBLOCKS_PUBLIC_KEY_US);
    }

    private PublicKey loadPublicKey(String base64Key) throws Exception {
        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePublic(spec);
    }

    /**
     * Verify Fireblocks webhook signature.
     *
     * @param rawBody   The raw request body as bytes
     * @param signature The base64-encoded signature from Fireblocks-Signature header
     * @return true if signature is valid, false otherwise
     */
    public boolean verifyWebhookSignature(byte[] rawBody, String signature) {
        try {
            byte[] signatureBytes = Base64.getDecoder().decode(signature);
            
            // SHA512withRSA uses PKCS#1 v1.5 padding
            Signature verifier = Signature.getInstance("SHA512withRSA");
            verifier.initVerify(publicKey);
            verifier.update(rawBody);
            
            return verifier.verify(signatureBytes);
        } catch (Exception e) {
            System.err.println("Signature verification failed: " + e.getMessage());
            return false;
        }
    }
}

// Usage: pass raw request body (byte[]) and Fireblocks-Signature header value
// boolean isValid = verifier.verifyWebhookSignature(rawBody, signatureHeader);

// IMPORTANT:
// Signature verification requires the raw request body.
// Use:
//
//   @RequestBody byte[] rawBody
//
// Do NOT bind the request to a POJO or String before verification,
// as this will alter the payload and break signature validation.
package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha512"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"fmt"
	"log"
)

// Select the appropriate key for your environment
const fireblocksPublicKeyUS = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0+6wd9OJQpK60ZI7qnZG
jjQ0wNFUHfRv85Tdyek8+ahlg1Ph8uhwl4N6DZw5LwLXhNjzAbQ8LGPxt36RUZl5
YlxTru0jZNKx5lslR+H4i936A4pKBjgiMmSkVwXD9HcfKHTp70GQ812+J0Fvti/v
4nrrUpc011Wo4F6omt1QcYsi4GTI5OsEbeKQ24BtUd6Z1Nm/EP7PfPxeb4CP8KOH
clM8K7OwBUfWrip8Ptljjz9BNOZUF94iyjJ/BIzGJjyCntho64ehpUYP8UJykLVd
CGcu7sVYWnknf1ZGLuqqZQt4qt7cUUhFGielssZP9N9x7wzaAIFcT3yQ+ELDu1SZ
dE4lZsf2uMyfj58V8GDOLLE233+LRsRbJ083x+e2mW5BdAGtGgQBusFfnmv5Bxqd
HgS55hsna5725/44tvxll261TgQvjGrTxwe7e5Ia3d2Syc+e89mXQaI/+cZnylNP
SwCCvx8mOM847T0XkVRX3ZrwXtHIA25uKsPJzUtksDnAowB91j7RJkjXxJcz3Vh1
4k182UFOTPRW9jzdWNSyWQGl/vpe9oQ4c2Ly15+/toBo4YXJeDdDnZ5c/O+KKadc
IMPBpnPrH/0O97uMPuED+nI6ISGOTMLZo35xJ96gPBwyG5s2QxIkKPXIrhgcgUnk
tSM7QYNhlftT4/yVvYnk0YcCAwEAAQ==
-----END PUBLIC KEY-----`

func loadPublicKey(pemKey string) (*rsa.PublicKey, error) {
	block, _ := pem.Decode([]byte(pemKey))
	if block == nil {
		return nil, fmt.Errorf("failed to parse PEM block")
	}
	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, fmt.Errorf("failed to parse public key: %w", err)
	}
	rsaPub, ok := pub.(*rsa.PublicKey)
	if !ok {
		return nil, fmt.Errorf("not an RSA public key")
	}
	return rsaPub, nil
}

// VerifyWebhookSignature verifies Fireblocks webhook signature.
// rawBody: The raw request body as bytes
// signatureB64: The base64-encoded signature from Fireblocks-Signature header
func VerifyWebhookSignature(rawBody []byte, signatureB64 string, publicKey *rsa.PublicKey) bool {
	signature, err := base64.StdEncoding.DecodeString(signatureB64)
	if err != nil {
		log.Printf("Failed to decode signature: %v", err)
		return false
	}
	
	hash := sha512.Sum512(rawBody)
	
	err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA512, hash[:], signature)
	if err != nil {
		log.Printf("Signature verification failed: %v", err)
		return false
	}
	return true
}

// Usage:
// publicKey, _ := loadPublicKey(fireblocksPublicKeyUS)
// isValid := VerifyWebhookSignature(rawBody, signatureHeader, publicKey)

// IMPORTANT:
// Signature verification requires the raw request body bytes.
// Use:
//
//   body, err := io.ReadAll(r.Body)
//
// Do NOT decode or unmarshal the body before verification,
// as this will modify the payload and invalidate the signature.