Vérifier la signature

Chaque requête de Kivoo est signée : vérifie l’en-tête Kivoo-Signature avant de croire son contenu, et traite chaque événement une seule fois.

N’importe qui peut envoyer une requête à ton URL. Avant d’agir sur un événement webhook, vérifie qu’il vient bien de Kivoo et qu’il n’a pas été modifié : c’est le rôle de l’en-tête Kivoo-Signature.

L’en-tête Kivoo-Signature

Kivoo-Signature: t=1790416800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t est l’heure de l’envoi, en secondes depuis le 1er janvier 1970 (UTC).
  • v1 est la signature : le HMAC-SHA256, en hexadécimal, de la chaîne <t>.<corps brut> (l’horodatage, un point, puis le corps exact de la requête), calculé avec le secret du webhook (la chaîne complète affichée à la création, whsec_ compris).

Pour vérifier :

  1. Lis le corps brut de la requête, avant tout décodage JSON : la signature porte sur ces octets exacts, un JSON re-sérialisé ne donnerait pas la même.
  2. Découpe l’en-tête : t et la ou les valeurs v1.
  3. Refuse une signature trop ancienne : Kivoo recommande une tolérance de 300 secondes (5 minutes) entre t et ton horloge. Cela empêche de rejouer une vieille requête interceptée.
  4. Calcule le HMAC-SHA256 de <t>.<corps brut> avec ton secret et compare-le à chaque v1 en temps constant. Une seule correspondance suffit.
  5. Si rien ne correspond, réponds 400 et ignore la requête.

Node.js

Ce module n’utilise que node:crypto :

verify-kivoo-signature.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';

// Signatures acceptées jusqu’à cinq minutes après leur envoi.
const TOLERANCE_SECONDS = 300;

// HMAC-SHA256 de "<t>.<corps brut>" avec le secret du webhook, en hexadécimal.
export function computeSignature(secret, timestamp, rawBody) {
  return createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
}

// Vrai si l’en-tête Kivoo-Signature signe ce corps brut, et n’est pas trop ancien.
export function verifyKivooSignature(secret, header, rawBody, now = Date.now()) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const [key, value = ''] = part.trim().split('=');
    if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === 'v1' && value) signatures.push(value);
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Math.floor(now / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = Buffer.from(computeSignature(secret, timestamp, rawBody), 'hex');
  return signatures.some((signature) => {
    const received = Buffer.from(signature, 'hex');
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Avec Express, garde le corps brut sur la route du webhook :

server.mjs
import express from 'express';
import { verifyKivooSignature } from './verify-kivoo-signature.mjs';

const app = express();

app.post('/webhooks/kivoo', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const signature = req.get('Kivoo-Signature');
  if (!verifyKivooSignature(process.env.KIVOO_WEBHOOK_SECRET, signature, rawBody)) {
    return res.status(400).send('Invalid signature');
  }
  const event = JSON.parse(rawBody);
  res.sendStatus(200); // réponds d’abord, en moins de 10 secondes
  // … puis traite `event`, une seule fois par `event.id` (voir plus bas).
});

app.listen(3000);

PHP

webhook.php
<?php

function verifyKivooSignature(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't' && ctype_digit($value)) {
            $timestamp = (int) $value;
        } elseif ($key === 'v1' && $value !== '') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_KIVOO_SIGNATURE'] ?? '';

if (!verifyKivooSignature(getenv('KIVOO_WEBHOOK_SECRET'), $header, $rawBody)) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);
http_response_code(200);
// … traite $event, une seule fois par $event['id'].

Traiter chaque événement une seule fois

Un même événement peut t’arriver plusieurs fois : après une tentative dont ta réponse s’est perdue, ou quand quelqu’un clique Renvoyer dans le journal des livraisons. L’en-tête Kivoo-Event-Id (égal à l’id de l’enveloppe) reste le même d’une livraison à l’autre : c’est ta clé d’idempotence.

  • Enregistre l’id de chaque événement traité, avec une contrainte d’unicité, dans la même transaction que ton traitement.
  • Si l’id est déjà connu, réponds 200 sans rien refaire.
  • Garde ces identifiants au moins trente jours, la durée du journal des livraisons.

Garde le secret secret

Le secret signe les requêtes à ta place : il ne doit vivre que sur ton serveur. S’il a fuité, régénère-le depuis la fiche du webhook, puis mets à jour ton serveur : l’ancien secret cesse aussitôt de signer.

Sur cette page