Vérifier les signatures
Vérifier la signature HMAC-SHA256 de l'en-tête X-Novafisko-Signature en PHP, Node.js, Python et C#, avec tolérance d'horloge et rotation du secret.
Votre URL de réception est publique : n'importe qui peut y envoyer une requête. La signature prouve qu'un appel vient bien de NovaFisko et que son contenu n'a pas été modifié en chemin. Ne traitez jamais un webhook sans l'avoir vérifiée.
Le principe
Chaque requête porte un en-tête de la forme :
X-Novafisko-Signature: t=1791191400,v1=5f2b8c1e0a7d4f6b9c3e2a1d8f7b6c5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
| Élément | Signification |
|---|---|
t |
Horodatage Unix de l'envoi, en secondes |
v1 |
Signature HMAC-SHA256 en hexadécimal minuscule, 64 caractères |
La signature est calculée ainsi :
v1 = HMAC_SHA256(secret, t + "." + raw_body)
où secret est la valeur whsec_... reçue à la création du point de réception et raw_body est le corps de la requête, octet pour octet.
Les quatre étapes
- Lire le corps brut. Récupérez les octets reçus avant toute analyse JSON.
- Extraire
tetv1de l'en-tête. - Contrôler l'horodatage. Refusez si
ts'écarte de plus de 5 minutes de votre horloge. - Recalculer et comparer. Calculez le HMAC et comparez-le à
v1avec une fonction à temps constant.
Signez le corps tel que reçu. Si vous analysez le JSON puis le sérialisez de nouveau, l'ordre des clés, les espaces ou l'encodage des caractères accentués changeront et la signature ne correspondra plus. C'est l'erreur la plus fréquente.
Pourquoi une tolérance d'horloge
L'horodatage fait partie de la donnée signée. Un attaquant qui intercepterait une requête valide ne pourrait donc pas la rejouer plus tard : passé 5 minutes, vous la refusez. Gardez l'horloge de votre serveur synchronisée par NTP.
Chaque nouvelle tentative de livraison est signée avec un horodatage neuf. Une livraison retentée une heure plus tard reste donc valide.
Pourquoi une comparaison à temps constant
Une comparaison ordinaire s'arrête au premier caractère différent. En mesurant le temps de réponse, un attaquant peut deviner la signature caractère par caractère. Les fonctions hash_equals, timingSafeEqual, compare_digest et FixedTimeEquals prennent toujours le même temps.
PHP
<?php
function verifyNovafiskoSignature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null) {
return false;
}
// Header format: t=<unix ts>,v1=<hex hmac>
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
if (! ctype_digit($timestamp) || $signature === '') {
return false;
}
// Reject stale or future-dated requests (replay protection)
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $signature); // constant-time comparison
}
$rawBody = file_get_contents('php://input'); // raw bytes, before any json_decode
$header = $_SERVER['HTTP_X_NOVAFISKO_SIGNATURE'] ?? null;
if (! verifyNovafiskoSignature($rawBody, $header, getenv('NOVAFISKO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
// Acknowledge first, process asynchronously
http_response_code(200);
echo 'ok';
Avec Laravel, utilisez $request->getContent() pour le corps brut et $request->header('X-Novafisko-Signature') pour l'en-tête.
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.NOVAFISKO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyNovafiskoSignature(rawBody, header, secret) {
if (!header) return false;
// Header format: t=<unix ts>,v1=<hex hmac>
const parts = Object.fromEntries(
header.split(",").map((pair) => {
const index = pair.indexOf("=");
return [pair.slice(0, index).trim(), pair.slice(index + 1).trim()];
})
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
// Reject stale or future-dated requests (replay protection)
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, "hex");
// timingSafeEqual throws when lengths differ: check first
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// express.raw keeps the body as a Buffer: never use express.json() on this route
app.post("/hooks/novafisko", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyNovafiskoSignature(req.body, req.get("X-Novafisko-Signature"), SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).send("ok"); // acknowledge first
queue.push(event); // then process asynchronously
});
app.listen(3000);
Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["NOVAFISKO_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify_novafisko_signature(raw_body: bytes, header: str | None, secret: bytes) -> bool:
if not header:
return False
# Header format: t=<unix ts>,v1=<hex hmac>
parts = dict(pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or not signature:
return False
# Reject stale or future-dated requests (replay protection)
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) # constant-time comparison
@app.post("/hooks/novafisko")
def novafisko_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not verify_novafisko_signature(raw_body, request.headers.get("X-Novafisko-Signature"), SECRET):
abort(400, "Invalid signature")
event = json.loads(raw_body)
enqueue(event) # process asynchronously
return "ok", 200
Avec FastAPI, lisez le corps avec await request.body(). Avec Django, utilisez request.body.
C#
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("NOVAFISKO_WEBHOOK_SECRET")!;
const int ToleranceSeconds = 300;
static bool VerifyNovafiskoSignature(byte[] rawBody, string? header, string secret, int tolerance)
{
if (string.IsNullOrEmpty(header)) return false;
// Header format: t=<unix ts>,v1=<hex hmac>
string? timestamp = null, signature = null;
foreach (var pair in header.Split(','))
{
var index = pair.IndexOf('=');
if (index < 0) continue;
var key = pair[..index].Trim();
var value = pair[(index + 1)..].Trim();
if (key == "t") timestamp = value;
if (key == "v1") signature = value;
}
if (!long.TryParse(timestamp, out var unix) || string.IsNullOrEmpty(signature)) return false;
// Reject stale or future-dated requests (replay protection)
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > tolerance) return false;
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
byte[] received;
try { received = Convert.FromHexString(signature); }
catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, received); // constant-time comparison
}
app.MapPost("/hooks/novafisko", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // raw bytes, before any JSON parsing
var rawBody = buffer.ToArray();
if (!VerifyNovafiskoSignature(rawBody, request.Headers["X-Novafisko-Signature"], secret, ToleranceSeconds))
{
return Results.BadRequest("Invalid signature");
}
// Acknowledge first, process asynchronously
return Results.Ok("ok");
});
app.Run();
Vérifier votre implémentation
Utilisez ce vecteur de test pour valider votre code sans attendre un vrai événement. Pensez à neutraliser le contrôle de l'horodatage pendant ce test.
| Élément | Valeur |
|---|---|
| Secret | whsec_test_secret |
| Horodatage | 1791191400 |
| Corps | {"id":"evt_test","event":"webhook.test"} |
| Chaîne signée | 1791191400.{"id":"evt_test","event":"webhook.test"} |
Calculez la signature attendue en ligne de commande, puis comparez-la au résultat de votre fonction :
printf '%s' '1791191400.{"id":"evt_test","event":"webhook.test"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret' -hex
Pour un essai de bout en bout, déclenchez un envoi réel avec POST /v1/firms/{firm}/webhooks/{id}/test.
Rotation du secret
Un secret ne peut pas être relu ni modifié. Pour le renouveler sans perdre d'événement :
- Créez un second point de réception avec la même URL et les mêmes événements. Vous recevez un nouveau secret.
- Déployez votre récepteur pour qu'il accepte les deux secrets : la requête est valide si l'un des deux produit la bonne signature.
- Supprimez l'ancien point de réception.
- Retirez l'ancien secret de votre configuration.
Pendant la courte période où les deux coexistent, chaque événement vous parvient deux fois, avec le même id. Votre dédoublonnage sur id l'absorbe.
def verify_any(raw_body, header, secrets):
# Accept the current and the previous secret during a rotation
return any(verify_novafisko_signature(raw_body, header, secret) for secret in secrets)
Renouvelez le secret sans attendre si vous soupçonnez une fuite, par exemple s'il est apparu dans un journal applicatif ou dans un dépôt de code.
Dépannage
| Symptôme | Cause probable |
|---|---|
| La signature ne correspond jamais | Le corps a été analysé puis sérialisé de nouveau. Signez les octets bruts |
| La signature échoue sur les textes accentués | Le corps a été transcodé. Il est en UTF-8, avec les caractères accentués non échappés |
| Tout est refusé pour horodatage | L'horloge du serveur dérive. Synchronisez-la par NTP |
| Échec après un changement de proxy | Un intermédiaire modifie le corps (compression, réécriture). Transmettez-le intact |
| Échec uniquement sur un point de réception | Mauvais secret. Comparez ses quatre derniers caractères à secret_hint |