// Vérifier la signature d'un webhook Tauraco (Standard Webhooks) — .NET 6+, sans dépendance.
// Verify a Tauraco webhook signature (Standard Webhooks) — .NET 6+, no dependency.
// Vecteur de référence (page « Guide des webhooks ») :
// secret whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
// webhook-id evt_exemple_0001
// webhook-timestamp 1793750400
// corps {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
// signature v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=
// Un secret vide ou illisible est refusé ; une signature candidate illisible est ignorée
// (on essaie la suivante : l'en-tête peut en porter plusieurs).
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
public static class VerifierWebhook
{
private const long ToleranceSeconds = 300;
/// : noms en minuscules ; : corps BRUT.
public static bool VerifySignature(string secret, IReadOnlyDictionary headers, byte[] body, long? now = null)
{
if (!headers.TryGetValue("webhook-id", out var id) ||
!headers.TryGetValue("webhook-timestamp", out var rawTimestamp) ||
!headers.TryGetValue("webhook-signature", out var signatures) ||
!long.TryParse(rawTimestamp.Trim(), out var timestamp))
{
return false;
}
var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(current - timestamp) > ToleranceSeconds) return false;
byte[] key;
try
{
key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret.Substring(6) : secret);
}
catch (FormatException)
{
return false; // secret illisible
}
if (key.Length == 0) return false; // clé vide : n'importe qui pourrait signer
var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var signed = new byte[prefix.Length + body.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(body, 0, signed, prefix.Length, body.Length);
using var hmac = new HMACSHA256(key);
var expected = hmac.ComputeHash(signed);
foreach (var candidate in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = candidate.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1") continue;
byte[] received;
try
{
received = Convert.FromBase64String(parts[1]);
}
catch (FormatException)
{
continue; // candidate illisible : essayer la suivante
}
if (CryptographicOperations.FixedTimeEquals(received, expected)) return true;
}
return false;
}
}