// 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; } }