Zum Inhalt springen
Dokumentation
Deutsch
App öffnen

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

  1. Den rohen Body lesen. Holen Sie die empfangenen Bytes vor jedem JSON-Parsing.
  2. t und v1 aus dem Header extrahieren.
  3. Den Zeitstempel kontrollieren. Lehnen Sie ab, wenn t um mehr als 5 Minuten von Ihrer Uhr abweicht.
  4. Neu berechnen und vergleichen. Berechnen Sie den HMAC und vergleichen Sie ihn mit v1 über eine Funktion mit konstanter Laufzeit.
Achtung

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:

  1. Erstellen Sie einen zweiten Endpunkt mit derselben URL und denselben Ereignissen. Sie erhalten ein neues Secret.
  2. Deployen Sie Ihren Empfänger so, dass er beide Secrets akzeptiert: Der Request ist gültig, wenn eines der beiden die richtige Signatur ergibt.
  3. Löschen Sie den alten Endpunkt.
  4. 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)
Tipp

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

Siehe auch