# Ověření podpisu webhooků

> Ověřením podpisu HMAC-SHA256 a časového razítka zkontrolujte, že požadavky webhooků přicházejí od Emailitu, s kódem pro Node.js, Python, PHP, Ruby, Go a Laravel.

Požadavky na URL vašeho webhooku může posílat kdokoli, kdo ji zná. Každý požadavek od Emailitu je podepsaný tajným klíčem vašeho webhooku, takže ověření podpisu dokazuje, že požadavek přišel od Emailitu a cestou se nezměnil. Kontrola časového razítka navíc zabrání tomu, aby někdo staré požadavky poslal znovu (útok opakováním). Obojí proveďte dřív, než na jakoukoli událost zareagujete.

## Jak podpis funguje

Každý webhook má vlastní tajný klíč: `whsec_` a za ním 64 hexadecimálních znaků. Uvidíte ho jednou, když webhook vytvoříte nebo tajný klíč vyměníte, a vrací ho také [Načtení webhooku](/cs/docs/api-reference/webhooks/get/).

U každého požadavku Emailit:

1. Vezme aktuální unixový čas v sekundách, například `1790846045`, a pošle ho v hlavičce `X-Emailit-Timestamp`.
2. Sestaví řetězec `{timestamp}.{raw body}`: časové razítko, tečku a přesné bajty těla požadavku.
3. Z tohoto řetězce spočítá HMAC-SHA256 a jako klíč použije celý tajný klíč včetně `whsec_`.
4. Výsledek zakódovaný hexadecimálně malými písmeny pošle v hlavičce `X-Emailit-Signature`.

Při ověření váš endpoint zopakuje kroky 2 a 3 s přijatým surovým tělem požadavku a svůj výsledek porovná s hlavičkou porovnáním v konstantním čase. Pak zkontroluje, že časové razítko je čerstvé. Doporučujeme toleranci 5 minut (300 sekund). Každé opakování se podepíše znovu s novým časovým razítkem, takže 5minutové okno opakování neodmítne.

## Ověřujte požadavky

Každý příklad načte surové tělo požadavku, požadavky s chybějícím, zastaralým nebo chybným podpisem odmítne s `401` a teprve potom zpracuje pole JSON.

**Node.js**

```javascript title="server.js"
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;

export function verifyEmailitSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyEmailitSignature(
    req.body,
    req.get('X-Emailit-Signature'),
    req.get('X-Emailit-Timestamp'),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const events = JSON.parse(req.body.toString('utf8'));
  // Queue the events for processing, then acknowledge.
  res.sendStatus(204);
});

app.listen(3000);
```

**Python**

```python title="app.py"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()  # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
    if not signature or not timestamp:
        return False
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/emailit")
def emailit_webhook():
    raw_body = request.get_data()  # raw bytes; don't use request.json first
    if not verify_emailit_signature(
        raw_body,
        request.headers.get("X-Emailit-Signature"),
        request.headers.get("X-Emailit-Timestamp"),
    ):
        abort(401)

    events = json.loads(raw_body)
    # Queue the events for processing, then acknowledge.
    return "", 204
```

**PHP**

```php title="webhook.php"
<?php
const TOLERANCE_SECONDS = 300;

function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
    if ($signature === '' || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_

if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);
```

**Ruby**

```ruby title="app.rb"
require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'

SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body, signature, timestamp)
  return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
  return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post '/webhooks/emailit' do
  raw_body = request.body.read # in Rails, use request.raw_post
  valid = verify_emailit_signature(
    raw_body,
    request.env['HTTP_X_EMAILIT_SIGNATURE'],
    request.env['HTTP_X_EMAILIT_TIMESTAMP']
  )
  halt 401, 'Invalid signature' unless valid

  events = JSON.parse(raw_body)
  # Queue the events for processing, then acknowledge.
  status 204
end
```

**Go**

```go title="main.go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

const toleranceSeconds = 300

func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false
	}
	age := time.Now().Unix() - ts
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

type Event struct {
	EventID string          `json:"event_id"`
	Type    string          `json:"type"`
	Data    json.RawMessage `json:"data"`
}

func emailitWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
	if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var events []Event
	if err := json.Unmarshal(rawBody, &events); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	// Queue the events for processing, then acknowledge.
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/webhooks/emailit", emailitWebhook)
	http.ListenAndServe(":8080", nil)
}
```

**Laravel**

```php title="app/Http/Controllers/EmailitWebhookController.php"
<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php:      Route::post('/webhooks/emailit', EmailitWebhookController::class);

namespace App\Http\Controllers;

use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class EmailitWebhookController extends Controller
{
    private const TOLERANCE_SECONDS = 300;

    public function __invoke(Request $request): Response
    {
        $rawBody = $request->getContent();
        $signature = (string) $request->header('X-Emailit-Signature', '');
        $timestamp = (string) $request->header('X-Emailit-Timestamp', '');
        $secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_

        $fresh = ctype_digit($timestamp)
            && abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');

        foreach (json_decode($rawBody, true) as $event) {
            ProcessEmailitEvent::dispatch($event); // a queued job
        }

        return response()->noContent();
    }
}
```

V Laravelu se u rout v `routes/api.php` nekontrolují tokeny CSRF a routy dostanou předponu `/api`, takže URL výše je `https://acme.com/api/webhooks/emailit`. Pokud routu zaregistrujete v `routes/web.php`, vyřaďte cestu z ověřování CSRF, jinak Laravel požadavky Emailitu odmítne s `419`.

## Otestujte ověřování

Před nasazením svou funkci vyzkoušejte na tomto známém příkladu. Zavolejte přímo část s HMAC, protože časové razítko je v minulosti a 5minutovou kontrolou neprojde.

| Vstup | Hodnota |
| --- | --- |
| Tajný klíč | `whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f` |
| Časové razítko | `1790846045` |
| Očekávaný podpis | `7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3` |

Surové tělo požadavku přesně v této podobě, bez koncového znaku nového řádku:

```json
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]
```

Pak u webhooku použijte **Send test** a ověřte, že nasazený endpoint přijme skutečný podepsaný požadavek. Viz [Nastavení webhooku](/cs/docs/webhooks/set-up/#send-a-test-event).

## Časté chyby

- **Zpracování před ověřením.** Frameworky, které JSON zpracovávají automaticky (`express.json()`, `request.json` ve Flasku, params v Rails), vám dají objekt, ne bajty, které Emailit podepsal. Na routě webhooku čtěte surové tělo požadavku.
- **Opětovná serializace JSON.** `JSON.stringify(req.body)` nebo `json.dumps(data)` vytvoří jiné mezery, pořadí klíčů nebo escapování, takže HMAC nebude souhlasit. Vždy hashujte tělo přesně tak, jak přišlo.
- **Úprava tajného klíče.** Použijte celou hodnotu včetně `whsec_` jako prostý text. Neodstraňujte předponu a nedekódujte klíč z hexadecimálního zápisu ani z Base64.
- **Podepsání špatného řetězce.** Vstupem je časové razítko, tečka a pak tělo. Pokud podepíšete jen tělo nebo části spojíte jiným oddělovačem, ověření selže.
- **Porovnání pomocí `==`.** Použijte funkci, která porovnává v konstantním čase, například `crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, `Rack::Utils.secure_compare` nebo `hmac.Equal`.
- **Špatně nastavené hodiny serveru.** Pokud se hodiny vašeho serveru rozcházejí, platné požadavky vypadnou z tolerance. Synchronizujte je přes NTP.
- **Starý nebo nesprávný tajný klíč.** Jakmile vyberete **Reset**, starý tajný klíč okamžitě přestane fungovat, a to i pro opakování. Každý webhook má vlastní tajný klíč, takže dva endpointy nemohou sdílet jeden.
- **Middleware, který přepisuje tělo.** Proxy, API brány nebo dekomprese těla, které změní bajty dřív, než je uvidí váš kód, podpis rozbijí. Ověřujte co nejblíž surovému požadavku.

Požadavky, které neprojdou ověřením, dostanou od vašeho endpointu `401` a [opakují se](/cs/docs/webhooks/retries-and-failures/) jako každé jiné selhání, takže pokud špatně nastavený tajný klíč opravíte do několika hodin, o nic nepřijdete.

## Související

  - [Požadavky webhooků](/cs/docs/webhooks/webhook-requests/)
  - [Opakování a selhání](/cs/docs/webhooks/retries-and-failures/)

---
Zdroj: https://emailit.com/cs/docs/webhooks/request-signature/
