Signaturen prüfen
Die HMAC-SHA256-Signatur des Headers X-Novafisko-Signature in PHP, Node.js, Python und C# prüfen, mit Uhrentoleranz und Rotation des Secrets.
Ihre Empfangs-URL ist öffentlich: Jeder kann einen Request dorthin senden. Die Signatur beweist, dass ein Aufruf tatsächlich von NovaFisko stammt und dass sein Inhalt unterwegs nicht verändert wurde. Verarbeiten Sie niemals einen Webhook, ohne sie geprüft zu haben.
Das Prinzip
Jeder Request trägt einen Header der Form:
X-Novafisko-Signature: t=1791191400,v1=5f2b8c1e0a7d4f6b9c3e2a1d8f7b6c5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
| Element | Bedeutung |
|---|---|
t |
Unix-Zeitstempel des Versands, in Sekunden |
v1 |
HMAC-SHA256-Signatur in hexadezimaler Kleinschreibung, 64 Zeichen |
Die Signatur wird wie folgt berechnet:
v1 = HMAC_SHA256(secret, t + "." + raw_body)
Dabei ist secret der Wert whsec_..., den Sie bei der Erstellung des Endpunkts erhalten haben, und raw_body der rohe Body des Requests, Byte für Byte.
Die vier Schritte
- Den rohen Body lesen. Holen Sie die empfangenen Bytes vor jedem JSON-Parsing.
tundv1aus dem Header extrahieren.- Den Zeitstempel kontrollieren. Lehnen Sie ab, wenn
tum mehr als 5 Minuten von Ihrer Uhr abweicht. - Neu berechnen und vergleichen. Berechnen Sie den HMAC und vergleichen Sie ihn mit
v1über eine Funktion mit konstanter Laufzeit.
Signieren Sie den Body so, wie er empfangen wurde. Wenn Sie das JSON parsen und anschließend erneut serialisieren, ändern sich die Reihenfolge der Schlüssel, die Leerzeichen oder die Kodierung der Zeichen mit Akzent, und die Signatur stimmt nicht mehr überein. Das ist der häufigste Fehler.
Warum eine Uhrentoleranz
Der Zeitstempel ist Teil der signierten Daten. Ein Angreifer, der einen gültigen Request abfängt, könnte ihn daher später nicht erneut einspielen: Nach 5 Minuten lehnen Sie ihn ab. Halten Sie die Uhr Ihres Servers per NTP synchron.
Jeder erneute Zustellversuch wird mit einem frischen Zeitstempel signiert. Eine Zustellung, die eine Stunde später wiederholt wird, bleibt daher gültig.
Warum ein Vergleich mit konstanter Laufzeit
Ein gewöhnlicher Vergleich bricht beim ersten abweichenden Zeichen ab. Durch Messen der Antwortzeit kann ein Angreifer die Signatur Zeichen für Zeichen erraten. Die Funktionen hash_equals, timingSafeEqual, compare_digest und FixedTimeEquals benötigen immer dieselbe Zeit.
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';
Mit Laravel verwenden Sie $request->getContent() für den rohen Body und $request->header('X-Novafisko-Signature') für den 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
Mit FastAPI lesen Sie den Body mit await request.body(). Mit Django verwenden Sie 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();
Ihre Implementierung prüfen
Verwenden Sie diesen Testvektor, um Ihren Code zu validieren, ohne auf ein echtes Ereignis zu warten. Denken Sie daran, die Kontrolle des Zeitstempels während dieses Tests auszuschalten.
| Element | Wert |
|---|---|
| Secret | whsec_test_secret |
| Zeitstempel | 1791191400 |
| Body | {"id":"evt_test","event":"webhook.test"} |
| Signierte Zeichenfolge | 1791191400.{"id":"evt_test","event":"webhook.test"} |
Berechnen Sie die erwartete Signatur auf der Kommandozeile und vergleichen Sie sie dann mit dem Ergebnis Ihrer Funktion:
printf '%s' '1791191400.{"id":"evt_test","event":"webhook.test"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret' -hex
Für einen End-to-End-Test lösen Sie mit POST /v1/firms/{firm}/webhooks/{id}/test einen echten Versand aus.
Rotation des Secrets
Ein Secret lässt sich weder erneut auslesen noch ändern. So erneuern Sie es, ohne ein Ereignis zu verlieren:
- Erstellen Sie einen zweiten Endpunkt mit derselben URL und denselben Ereignissen. Sie erhalten ein neues Secret.
- Deployen Sie Ihren Empfänger so, dass er beide Secrets akzeptiert: Der Request ist gültig, wenn eines der beiden die richtige Signatur ergibt.
- Löschen Sie den alten Endpunkt.
- Entfernen Sie das alte Secret aus Ihrer Konfiguration.
In der kurzen Zeit, in der beide nebeneinander bestehen, erreicht Sie jedes Ereignis zweimal, mit derselben id. Ihre Duplikaterkennung anhand der id fängt das ab.
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)
Erneuern Sie das Secret unverzüglich, wenn Sie ein Leck vermuten, zum Beispiel wenn es in einem Anwendungslog oder in einem Code-Repository aufgetaucht ist.
Fehlerbehebung
| Symptom | Wahrscheinliche Ursache |
|---|---|
| Die Signatur stimmt nie überein | Der Body wurde geparst und erneut serialisiert. Signieren Sie die rohen Bytes |
| Die Signatur schlägt bei Texten mit Akzenten fehl | Der Body wurde umkodiert. Er ist in UTF-8, mit nicht maskierten Akzentzeichen |
| Alles wird wegen des Zeitstempels abgelehnt | Die Uhr des Servers geht falsch. Synchronisieren Sie sie per NTP |
| Fehlschlag nach einem Proxy-Wechsel | Eine Zwischenstation verändert den Body (Komprimierung, Umschreibung). Leiten Sie ihn unverändert weiter |
| Fehlschlag nur bei einem einzelnen Endpunkt | Falsches Secret. Vergleichen Sie seine letzten vier Zeichen mit secret_hint |