# Vérifier la signature des webhooks

> Vérifiez l’en-tête Nouko-Signature (HMAC-SHA256) en Node.js, Python, PHP, Go, Java et Ruby, avec les pièges à éviter.

Source : https://nouko.openclouds.fr/docs/signature

Chaque webhook porte l’en-tête `Nouko-Signature: t=<horodatage>,v1=<signature>`. Vérifiez-le avant de faire confiance au contenu.

```
v1 = hex( HMAC-SHA256( secret_du_webhook, t + "." + corps_brut ) )
```

1. Séparez `t` et `v1` dans l’en-tête.
2. Refusez si `t` est éloigné de plus de 300 secondes (5 minutes) de l’heure actuelle.
3. Calculez le HMAC sur `t + "." + corps_brut` avec votre secret.
4. Comparez en temps constant avec `v1`.

**Node.js**

```javascript
import crypto from 'node:crypto';

// rawBody : le corps EXACT reçu (Buffer ou string), avant tout JSON.parse
export function verifyNouko(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(parts.t + '.').update(rawBody).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import hmac, hashlib, time

def verify_nouko(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    if "t" not in parts or "v1" not in parts:
        return False
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

**PHP**

```php
function verify_nouko(string $raw, string $header, string $secret): bool {
    $p = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
        $p[$k] = $v;
    }
    if (empty($p['t']) || empty($p['v1'])) return false;
    if (abs(time() - (int) $p['t']) > 300) return false;
    $expected = hash_hmac('sha256', $p['t'] . '.' . $raw, $secret);
    return hash_equals($expected, $p['v1']);
}
```

**Go**

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

func VerifyNouko(raw []byte, header, secret string) bool {
	p := map[string]string{}
	for _, part := range strings.Split(header, ",") {
		if kv := strings.SplitN(strings.TrimSpace(part), "=", 2); len(kv) == 2 {
			p[kv[0]] = kv[1]
		}
	}
	ts, err := strconv.ParseInt(p["t"], 10, 64)
	if err != nil {
		return false
	}
	if d := time.Now().Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(p["t"] + "."))
	mac.Write(raw)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(p["v1"]))
}
```

**Java**

```java
// Java 17+
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

public final class NoukoSignature {
  public static boolean verify(byte[] rawBody, String header, String secret) throws Exception {
    Map<String, String> p = new HashMap<>();
    for (String part : header.split(",")) {
      String[] kv = part.trim().split("=", 2);
      if (kv.length == 2) p.put(kv[0], kv[1]);
    }
    String t = p.get("t"), v1 = p.get("v1");
    if (t == null || v1 == null) return false;
    if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > 300) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
    String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
    return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                 v1.getBytes(StandardCharsets.UTF_8));
  }
}
```

**Ruby**

```ruby
require "openssl"

def verify_nouko(raw_body, header, secret)
  parts = header.split(",").map { |p| p.strip.split("=", 2) }.select { |kv| kv.size == 2 }.to_h
  return false unless parts["t"] && parts["v1"]
  return false if (Time.now.to_i - parts["t"].to_i).abs > 300
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.#{raw_body}")
  OpenSSL.secure_compare(expected, parts["v1"])
end
```

## Pièges fréquents

- **Corps re-sérialisé** : signez le corps exact reçu, pas `JSON.stringify(JSON.parse(body))`. Désactivez le parseur JSON automatique sur cette route.
- **Horloge décalée** : synchronisez votre serveur (NTP), sinon les messages valides sont refusés.
- **Mauvais secret** : chaque adresse webhook a son propre secret ; sandbox et production sont différents.
- **Comparaison simple** : `==` laisse fuiter des informations ; utilisez la comparaison en temps constant.
