Handtekeningen verifiëren
De HMAC-SHA256-handtekening van de header X-Novafisko-Signature verifiëren in PHP, Node.js, Python en C#, met kloktolerantie en rotatie van het geheim.
Uw ontvangst-URL is publiek: iedereen kan er een request naartoe sturen. De handtekening bewijst dat een aanroep wel degelijk van NovaFisko komt en dat de inhoud onderweg niet is gewijzigd. Verwerk nooit een webhook zonder de handtekening te hebben geverifieerd.
Het principe
Elke request draagt een header van de vorm:
X-Novafisko-Signature: t=1791191400,v1=5f2b8c1e0a7d4f6b9c3e2a1d8f7b6c5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
| Element | Betekenis |
|---|---|
t |
Unix-tijdstempel van de verzending, in seconden |
v1 |
HMAC-SHA256-handtekening in hexadecimale kleine letters, 64 tekens |
De handtekening wordt als volgt berekend:
v1 = HMAC_SHA256(secret, t + "." + raw_body)
waarbij secret de waarde whsec_... is die u bij het aanmaken van het eindpunt hebt ontvangen en raw_body de body van de request is, byte voor byte.
De vier stappen
- De ruwe body lezen. Haal de ontvangen bytes op vóór elke JSON-parsing.
tenv1uit de header halen.- De tijdstempel controleren. Weiger als
tmeer dan 5 minuten van uw klok afwijkt. - Herberekenen en vergelijken. Bereken de HMAC en vergelijk hem met
v1met een functie in constante tijd.
Onderteken de body zoals ontvangen. Als u de JSON parst en daarna opnieuw serialiseert, veranderen de volgorde van de sleutels, de spaties of de codering van de tekens met accenten en klopt de handtekening niet meer. Dat is de meest voorkomende fout.
Waarom een kloktolerantie
De tijdstempel maakt deel uit van de ondertekende gegevens. Een aanvaller die een geldige request onderschept, kan ze dus later niet opnieuw afspelen: na 5 minuten weigert u ze. Houd de klok van uw server gesynchroniseerd via NTP.
Elke nieuwe leveringspoging wordt ondertekend met een nieuwe tijdstempel. Een levering die een uur later opnieuw wordt geprobeerd, blijft dus geldig.
Waarom een vergelijking in constante tijd
Een gewone vergelijking stopt bij het eerste verschillende teken. Door de antwoordtijd te meten kan een aanvaller de handtekening teken per teken raden. De functies hash_equals, timingSafeEqual, compare_digest en FixedTimeEquals nemen altijd evenveel tijd in beslag.
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';
Gebruik met Laravel $request->getContent() voor de ruwe body en $request->header('X-Novafisko-Signature') voor de header.
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
Lees met FastAPI de body met await request.body(). Gebruik met Django 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();
Uw implementatie verifiëren
Gebruik deze testvector om uw code te valideren zonder op een echte gebeurtenis te wachten. Denk eraan de controle van de tijdstempel tijdens deze test uit te schakelen.
| Element | Waarde |
|---|---|
| Geheim | whsec_test_secret |
| Tijdstempel | 1791191400 |
| Body | {"id":"evt_test","event":"webhook.test"} |
| Ondertekende string | 1791191400.{"id":"evt_test","event":"webhook.test"} |
Bereken de verwachte handtekening op de opdrachtregel en vergelijk ze daarna met het resultaat van uw functie:
printf '%s' '1791191400.{"id":"evt_test","event":"webhook.test"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret' -hex
Voor een test van begin tot eind activeert u een echte verzending met POST /v1/firms/{firm}/webhooks/{id}/test.
Rotatie van het geheim
Een geheim kan niet opnieuw worden gelezen of gewijzigd. Om het te vernieuwen zonder een gebeurtenis te verliezen:
- Maak een tweede eindpunt aan met dezelfde URL en dezelfde gebeurtenissen. U ontvangt een nieuw geheim.
- Rol uw ontvanger uit zodat hij beide geheimen aanvaardt: de request is geldig als een van beide de juiste handtekening oplevert.
- Verwijder het oude eindpunt.
- Haal het oude geheim uit uw configuratie.
Tijdens de korte periode waarin beide naast elkaar bestaan, bereikt elke gebeurtenis u twee keer, met dezelfde id. Uw ontdubbeling op id vangt dat op.
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)
Vernieuw het geheim onmiddellijk als u een lek vermoedt, bijvoorbeeld als het in een applicatielog of in een coderepository is verschenen.
Probleemoplossing
| Symptoom | Waarschijnlijke oorzaak |
|---|---|
| De handtekening klopt nooit | De body is geparst en daarna opnieuw geserialiseerd. Onderteken de ruwe bytes |
| De handtekening faalt bij teksten met accenten | De body is getranscodeerd. Hij is in UTF-8, met niet-geëscapete tekens met accenten |
| Alles wordt geweigerd wegens de tijdstempel | De klok van de server wijkt af. Synchroniseer ze via NTP |
| Fout na een wijziging van proxy | Een tussenschakel wijzigt de body (compressie, herschrijving). Geef hem ongewijzigd door |
| Fout bij slechts één eindpunt | Verkeerd geheim. Vergelijk de laatste vier tekens ervan met secret_hint |