Aller au contenu
Documentation
Français
Ouvrir l'application

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

  1. Lire le corps brut. Récupérez les octets reçus avant toute analyse JSON.
  2. Extraire t et v1 de l'en-tête.
  3. Contrôler l'horodatage. Refusez si t s'écarte de plus de 5 minutes de votre horloge.
  4. Recalculer et comparer. Calculez le HMAC et comparez-le à v1 avec une fonction à temps constant.
Attention

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 :

  1. Créez un second point de réception avec la même URL et les mêmes événements. Vous recevez un nouveau secret.
  2. 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.
  3. Supprimez l'ancien point de réception.
  4. 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)
Astuce

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

Voir aussi