# Vérifier les signatures

Vérifiez qu’une livraison vient de AdProMaxPay et n’a été ni modifiée ni rejouée, dans dix langages.

N’importe qui peut envoyer une requête à votre adresse de webhook. Avant de croire une livraison,
vérifiez sa signature : elle prouve que AdProMaxPay a envoyé ce corps exact, récemment, avec le secret
de votre endpoint.

## L’en-tête de signature

Chaque livraison porte un en-tête `X-AdProMaxPay-Signature` :

* `t` est l’heure de signature, en secondes Unix ;
* `v1` est le HMAC-SHA256, en hexadécimal, du texte `t` + `.` + le corps brut, avec pour clé le
  secret de signature de votre endpoint (`apx_whsec_…`, utilisé en entier comme clé).

Pendant le renouvellement d’un secret, l’en-tête porte deux valeurs `v1`, une par secret. Une
livraison est authentique quand **l’une** des valeurs `v1` correspond.

```text
X-AdProMaxPay-Signature: t=1790851200,v1=d2e39688034d…9a9879e
```

## Vérifier une livraison

1. Lisez le **corps brut**, exactement tel que reçu, avant tout décodage JSON. Un corps décodé puis
   réécrit ne correspond plus à sa signature.
2. Découpez l’en-tête sur `,`, puis chaque partie sur le premier `=` : gardez `t` et chaque `v1`.
3. Refusez une livraison dont le `t` s’écarte de plus de **cinq minutes** de votre horloge : une
   ancienne livraison peut être un rejeu.
4. Calculez le HMAC-SHA256 de `t` + `.` + le corps brut avec votre secret, en hexadécimal.
5. Comparez-le à chaque `v1` en **temps constant**. Si l’un correspond, la livraison est
   authentique.

Répondez `400` à une livraison qui échoue, et n’en faites rien. Les fonctions de cette section font les cinq
étapes ; chacune est vérifiée sur le même exemple signé.

```bash title="verify-signature.sh"
# Usage: verify_signature <file holding the raw body> "<header>" "<secret>"
# A check by hand; your server should verify in its own language.
# NOW replaces the clock, for tests.
verify_signature() {
  local body_file="$1" header="$2" secret="$3"
  local now="${NOW:-$(date +%s)}" timestamp expected
  timestamp=$(printf '%s\n' "$header" | tr ',' '\n' \
    | sed -n 's/^ *t=//p' | head -n 1)
  case "$timestamp" in '' | *[!0-9]*) return 1 ;; esac
  [ $((now - timestamp)) -le 300 ] || return 1
  [ $((timestamp - now)) -le 300 ] || return 1
  expected=$({ printf '%s.' "$timestamp"; cat "$body_file"; } \
    | openssl dgst -sha256 -hmac "$secret" | sed 's/^.*= //')
  printf '%s\n' "$header" | tr ',' '\n' | sed 's/^ *//' \
    | grep -qx "v1=$expected"
}
```

```js title="verify-signature.mjs"
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// (a Buffer or a string) with `secret` and is under five minutes old.
export function verifySignature(rawBody, header, secret, now) {
  let timestamp = null
  const signatures = []
  for (const part of String(header).split(',')) {
    const [key, value] = part.trim().split('=', 2)
    if (key === 't')
      timestamp = value
    else if (key === 'v1' && value !== undefined)
      signatures.push(value)
  }
  if (timestamp === null || !/^\d+$/.test(timestamp))
    return false
  const clock = now ?? Math.floor(Date.now() / 1000)
  if (Math.abs(clock - Number(timestamp)) > TOLERANCE_SECONDS)
    return false
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length
      && timingSafeEqual(given, expected)
  })
}
```

```ts title="verify-signature.ts"
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// with `secret` and is under five minutes old.
export function verifySignature(
  rawBody: Buffer | string,
  header: string,
  secret: string,
  now: number = Math.floor(Date.now() / 1000),
): boolean {
  let timestamp: string | null = null
  const signatures: string[] = []
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=', 2)
    if (key === 't' && value !== undefined)
      timestamp = value
    else if (key === 'v1' && value !== undefined)
      signatures.push(value)
  }
  if (timestamp === null || !/^\d+$/.test(timestamp))
    return false
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
    return false
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length
      && timingSafeEqual(given, expected)
  })
}
```

```python title="verify_signature.py"
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_signature(raw_body, header, secret, now=None):
    """True when header (X-AdProMaxPay-Signature) signs raw_body
    (bytes) with secret and is under five minutes old."""
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if not (timestamp and timestamp.isascii() and timestamp.isdigit()):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256)
    digest = expected.hexdigest().encode()
    return any(hmac.compare_digest(digest, s.encode()) for s in signatures)
```

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

// True when $header (X-AdProMaxPay-Signature) signs $rawBody
// with $secret and is under five minutes old.
function verify_signature(
    string $rawBody,
    string $header,
    string $secret,
    ?int $now = null
): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if ($pair[0] === 't' && isset($pair[1])) {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1' && isset($pair[1])) {
            $signatures[] = $pair[1];
        }
    }
    if ($timestamp === null || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $signed = $timestamp . '.' . $rawBody;
    $expected = hash_hmac('sha256', $signed, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}
```

```ruby title="verify_signature.rb"
require 'openssl'

TOLERANCE_SECONDS = 300

# True when header (X-AdProMaxPay-Signature) signs raw_body
# with secret and is under five minutes old. Ruby 3.0 or later.
def verify_signature(raw_body, header, secret, now = Time.now.to_i)
  timestamp = nil
  signatures = []
  header.split(',').each do |part|
    key, value = part.strip.split('=', 2)
    timestamp = value if key == 't'
    signatures << value if key == 'v1' && value
  end
  return false unless timestamp&.match?(/\A\d+\z/)
  return false if (now - timestamp.to_i).abs > TOLERANCE_SECONDS

  signed = "#{timestamp}.".b + raw_body.b
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
  signatures.any? do |signature|
    OpenSSL.secure_compare(expected, signature)
  end
end
```

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

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

// VerifySignature reports whether header (X-AdProMaxPay-Signature)
// signs rawBody with secret and is under five minutes old.
func VerifySignature(rawBody []byte, header, secret string, now time.Time) bool {
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, found := strings.Cut(strings.TrimSpace(part), "=")
		switch {
		case found && key == "t":
			timestamp = value
		case found && key == "v1":
			signatures = append(signatures, value)
		}
	}
	if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" {
		return false
	}
	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	age := now.Unix() - signedAt
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, signature := range signatures {
		given, err := hex.DecodeString(signature)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```

```java title="WebhookSignature.java"
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignature {
    private static final long TOLERANCE_SECONDS = 300;

    // True when header (X-AdProMaxPay-Signature) signs rawBody
    // with secret and is under five minutes old. Java 17 or later.
    public static boolean verify(byte[] rawBody, String header, String secret, long now)
            throws GeneralSecurityException {
        String timestamp = null;
        List<String> signatures = new ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length < 2) {
                continue;
            }
            if (pair[0].equals("t")) {
                timestamp = pair[1];
            } else if (pair[0].equals("v1")) {
                signatures.add(pair[1]);
            }
        }
        if (timestamp == null || !timestamp.matches("\\d{1,18}")) {
            return false;
        }
        if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
            return false;
        }
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        byte[] expected = mac.doFinal(rawBody);
        for (String signature : signatures) {
            try {
                byte[] given = HexFormat.of().parseHex(signature);
                if (MessageDigest.isEqual(expected, given)) {
                    return true;
                }
            } catch (IllegalArgumentException notHexadecimal) {
                // Not one of our signatures.
            }
        }
        return false;
    }

    public static boolean verify(byte[] rawBody, String header, String secret)
            throws GeneralSecurityException {
        return verify(rawBody, header, secret, System.currentTimeMillis() / 1000);
    }
}
```

```csharp title="WebhookSignature.cs"
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public static class WebhookSignature
{
    private const long ToleranceSeconds = 300;

    // True when header (X-AdProMaxPay-Signature) signs rawBody
    // with secret and is under five minutes old.
    public static bool Verify(byte[] rawBody, string header, string secret, long? now = null)
    {
        string? timestamp = null;
        var signatures = new List<string>();
        foreach (var part in header.Split(','))
        {
            var pair = part.Trim().Split('=', 2);
            if (pair.Length < 2) continue;
            if (pair[0] == "t") timestamp = pair[1];
            else if (pair[0] == "v1") signatures.Add(pair[1]);
        }
        if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var signedAt))
            return false;
        var clock = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(clock - signedAt) > ToleranceSeconds)
            return false;

        var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
        var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
        foreach (var signature in signatures)
        {
            try
            {
                var given = Convert.FromHexString(signature);
                if (CryptographicOperations.FixedTimeEquals(expected, given))
                    return true;
            }
            catch (FormatException)
            {
                // Not one of our signatures.
            }
        }
        return false;
    }
}
```

```rust title="signature.rs"
// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;

const TOLERANCE_SECONDS: i64 = 300;

/// True when `header` (X-AdProMaxPay-Signature) signs `raw_body` with
/// `secret` and is under five minutes old. `now` is in Unix seconds.
pub fn verify_signature(raw_body: &[u8], header: &str, secret: &str, now: i64) -> bool {
    let mut timestamp = None;
    let mut signatures = Vec::new();
    for part in header.split(',') {
        match part.trim().split_once('=') {
            Some(("t", value)) => timestamp = Some(value),
            Some(("v1", value)) => signatures.push(value),
            _ => {}
        }
    }
    let Some(timestamp) = timestamp else {
        return false;
    };
    if timestamp.is_empty() || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) {
        return false;
    }
    let Ok(signed_at) = timestamp.parse::<i64>() else {
        return false;
    };
    if (now - signed_at).abs() > TOLERANCE_SECONDS {
        return false;
    }
    signatures.iter().any(|signature| {
        let Ok(given) = hex::decode(signature) else {
            return false;
        };
        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
            .expect("HMAC takes a key of any length");
        mac.update(timestamp.as_bytes());
        mac.update(b".");
        mac.update(raw_body);
        // Compares in constant time.
        mac.verify_slice(&given).is_ok()
    })
}
```

## Lire le corps brut

La plupart des frameworks décodent le JSON avant que votre code ne s’exécute. Demandez plutôt les
octets bruts, sur la seule route du webhook.

* **Node** : avec Express, montez `express.raw({ type: 'application/json' })` sur la route ;
  `req.body` est alors un `Buffer`. Avec `node:http`, rassemblez les morceaux de la requête, comme
  dans le [démarrage rapide carte](https://pay-docs.adpromax.com/fr/get-started/quickstart-card#6-traiter-lévénement).
* **PHP** : lisez `php://input`. Dans Laravel, `$request->getContent()`.
* **Python** : dans Flask, `request.get_data()` ; dans Django, `request.body`.
* **Ruby** : dans Rails, `request.raw_post` ; dans Sinatra, `request.body.read`.
* **Go** : `io.ReadAll(r.Body)`.
* **Java** : dans Spring, un paramètre `@RequestBody byte[]`.
* **C#** : copiez `Request.Body` dans un `MemoryStream` avant toute liaison de modèle.
* **Rust** : dans axum, l’extracteur `Bytes`.

```bash
# From a terminal, send the bytes exactly as they are: --data-binary.
# -d would strip the line breaks, and the signature would not match.
curl -i http://localhost:4242/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
  --data-binary @event.json
```

```js title="express.mjs"
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }), (req, res) => {
    const header = req.get('X-AdProMaxPay-Signature') ?? ''
    const secret = process.env.APX_WEBHOOK_SECRET
    if (!verifySignature(req.body, header, secret))
      return res.sendStatus(400)
    const event = JSON.parse(req.body.toString('utf8'))
    res.sendStatus(200)
    handle(event) // your own code, after answering
  })
```

```ts title="express.ts"
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) => {
    const rawBody = req.body as Buffer
    const header = req.get('X-AdProMaxPay-Signature') ?? ''
    const secret = process.env.APX_WEBHOOK_SECRET ?? ''
    if (!verifySignature(rawBody, header, secret))
      return res.sendStatus(400)
    const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent
    res.sendStatus(200)
    handle(event) // your own code, after answering
  })
```

```python title="views.py"
@csrf_exempt
@require_POST
def webhook(request):
    header = request.headers.get("X-AdProMaxPay-Signature", "")
    secret = os.environ["APX_WEBHOOK_SECRET"]
    if not verify_signature(request.body, header, secret):
        return HttpResponse(status=400)
    event = json.loads(request.body)
    # Your own code, quickly: answer within 10 seconds.
    return HttpResponse(status=200)
```

```php title="web.php"
Route::post('/webhooks/payments', function (Request $request) {
    $valid = verify_signature(
        $request->getContent(),
        $request->header('X-AdProMaxPay-Signature', ''),
        getenv('APX_WEBHOOK_SECRET'),
    );
    abort_unless($valid, 400);
    $event = json_decode($request->getContent(), true);
    // Your own code, quickly: answer within 10 seconds.
    return response()->noContent();
});
```

```ruby title="webhooks_controller.rb"
class WebhooksController < ApplicationController
  skip_forgery_protection

  def create
    valid = verify_signature(
      request.raw_post,
      request.headers['X-AdProMaxPay-Signature'].to_s,
      ENV.fetch('APX_WEBHOOK_SECRET')
    )
    return head :bad_request unless valid

    event = JSON.parse(request.raw_post)
    # Your own code, quickly: answer within 10 seconds.
    head :ok
  end
end
```

```go title="handler.go"
func webhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	header := r.Header.Get("X-AdProMaxPay-Signature")
	secret := os.Getenv("APX_WEBHOOK_SECRET")
	if !VerifySignature(rawBody, header, secret, time.Now()) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// Your own code, quickly: answer within 10 seconds.
	w.WriteHeader(http.StatusOK)
}
```

```java title="WebhookController.java"
@RestController
public class WebhookController {
    @PostMapping("/webhooks/payments")
    public ResponseEntity<Void> receive(
            @RequestBody byte[] rawBody,
            @RequestHeader("X-AdProMaxPay-Signature") String header)
            throws GeneralSecurityException {
        String secret = System.getenv("APX_WEBHOOK_SECRET");
        if (!WebhookSignature.verify(rawBody, header, secret)) {
            return ResponseEntity.badRequest().build();
        }
        // Your own code, quickly: answer within 10 seconds.
        return ResponseEntity.ok().build();
    }
}
```

```csharp title="Program.cs"
app.MapPost("/webhooks/payments", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var valid = WebhookSignature.Verify(
        buffer.ToArray(),
        request.Headers["X-AdProMaxPay-Signature"].ToString(),
        Environment.GetEnvironmentVariable("APX_WEBHOOK_SECRET")!);
    if (!valid)
        return Results.BadRequest();
    // Your own code, quickly: answer within 10 seconds.
    return Results.Ok();
});
```

```rust title="handler.rs"
// axum: `Bytes` is the body exactly as it was received.
async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let header = headers
        .get("x-adpromaxpay-signature")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("");
    let secret = std::env::var("APX_WEBHOOK_SECRET").unwrap_or_default();
    if !verify_signature(&body, header, &secret, unix_now()) {
        return StatusCode::BAD_REQUEST;
    }
    // Your own code, quickly: answer within 10 seconds.
    StatusCode::OK
}
```

## Quand vous renouvelez le secret

Renouvelez le secret d’un endpoint sur le tableau de bord quand il a pu fuiter, ou par habitude.
Vous choisissez combien de temps l’ancien secret continue de signer : pas du tout, une heure ou un
jour. Pendant ce temps, chaque livraison porte deux valeurs `v1`, si bien que votre ancienne et
votre nouvelle configuration l’acceptent toutes deux. Installez le nouveau secret avant la fin du
chevauchement.

## Tester votre gestionnaire

Signez vous-même un corps et envoyez-le à votre gestionnaire : un `2xx` veut dire que votre
vérification accepte une bonne livraison. Changez un caractère du corps et renvoyez-le : votre
gestionnaire doit répondre `400`.

```bash title="Terminal"
SECRET="$APX_WEBHOOK_SECRET"
BODY='{"id":"evt_handler_test","object":"event",
"type":"payment_intent.succeeded","data":{"object":{}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

curl -i http://localhost:4242/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"
```
