diff --git a/README.md b/README.md index 6f4dc35..48c13c0 100644 --- a/README.md +++ b/README.md @@ -247,11 +247,22 @@ var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAna ## Webhooks -Termii can send delivery/report callbacks to an endpoint you own. The SDK includes receiver-side models that can be used with ASP.NET Core model binding: +Termii can send delivery/report callbacks to an endpoint you own. Verify the raw request body with the `X-Termii-Signature` header before trusting the payload: ```csharp -app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) => +app.MapPost("/webhooks/termii", async (HttpRequest request) => { + using var reader = new StreamReader(request.Body); + var payload = await reader.ReadToEndAsync(); + var signature = request.Headers["X-Termii-Signature"].ToString(); + + if (!TermiiWebhookSignature.Verify(payload, signature, "your-webhook-secret")) + { + return Results.Unauthorized(); + } + + var webhookEvent = JsonSerializer.Deserialize(payload); + if (webhookEvent.Status == "delivered") { Console.WriteLine($"Delivered message {webhookEvent.MessageId}"); @@ -387,6 +398,7 @@ Implemented in the current SDK: - Campaigns: send campaigns, list campaigns, fetch campaign history, retry failed campaigns, and manage phonebooks. - Contacts: list, add, upload, and delete phonebook contacts. - Product emails: send template-based notification emails. +- Webhooks: delivery/report event models and HMAC SHA512 signature verification. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 47fd2d1..f83df98 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -81,6 +81,7 @@ The following SDK support is not an outbound Termii API call. | Area | Capability | Method | Path | Status | Notes | | --- | --- | --- | --- | --- | --- | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | +| Insights | Webhook signature verification | N/A | Consumer webhook endpoint | Implemented | `X-Termii-Signature` HMAC SHA512 verification covered by #45. | ## Postman Collection Reconciliation diff --git a/src/Termii/Webhooks/TermiiWebhookSignature.cs b/src/Termii/Webhooks/TermiiWebhookSignature.cs new file mode 100644 index 0000000..875e2d9 --- /dev/null +++ b/src/Termii/Webhooks/TermiiWebhookSignature.cs @@ -0,0 +1,125 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Termii; + +public static class TermiiWebhookSignature +{ + private const string Sha512Prefix = "sha512="; + + public static bool Verify( + string payload, + string? signature, + string secretKey) + { + if (payload is null) + { + throw new ArgumentNullException(nameof(payload)); + } + + return Verify(Encoding.UTF8.GetBytes(payload), signature, secretKey); + } + + public static bool Verify( + byte[] payload, + string? signature, + string secretKey) + { + if (payload is null) + { + throw new ArgumentNullException(nameof(payload)); + } + + if (string.IsNullOrWhiteSpace(secretKey)) + { + throw new ArgumentException("A Termii webhook secret key is required.", nameof(secretKey)); + } + + if (!TryDecodeSignature(signature, out var receivedSignature)) + { + return false; + } + + using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey)); + var expectedSignature = hmac.ComputeHash(payload); + + return FixedTimeEquals(expectedSignature, receivedSignature); + } + + private static bool TryDecodeSignature(string? signature, out byte[] value) + { + value = Array.Empty(); + + if (string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + var normalized = signature!.Trim(); + + if (normalized.StartsWith(Sha512Prefix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(Sha512Prefix.Length); + } + + if (normalized.Length != 128) + { + return false; + } + + var bytes = new byte[64]; + + for (var i = 0; i < bytes.Length; i++) + { + var high = FromHex(normalized[i * 2]); + var low = FromHex(normalized[(i * 2) + 1]); + + if (high < 0 || low < 0) + { + return false; + } + + bytes[i] = (byte)((high << 4) | low); + } + + value = bytes; + return true; + } + + private static int FromHex(char value) + { + if (value >= '0' && value <= '9') + { + return value - '0'; + } + + if (value >= 'a' && value <= 'f') + { + return value - 'a' + 10; + } + + if (value >= 'A' && value <= 'F') + { + return value - 'A' + 10; + } + + return -1; + } + + private static bool FixedTimeEquals(byte[] left, byte[] right) + { + if (left.Length != right.Length) + { + return false; + } + + var difference = 0; + + for (var i = 0; i < left.Length; i++) + { + difference |= left[i] ^ right[i]; + } + + return difference == 0; + } +} diff --git a/tests/Termii.Tests/TermiiWebhookEventTests.cs b/tests/Termii.Tests/TermiiWebhookEventTests.cs index 823f0a9..0dd7c54 100644 --- a/tests/Termii.Tests/TermiiWebhookEventTests.cs +++ b/tests/Termii.Tests/TermiiWebhookEventTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Security.Cryptography; +using System.Text; using Termii; using Xunit; @@ -74,4 +76,87 @@ public void CanDeserializeFailedReportPayload() Assert.Equal("Insufficient balance", webhookEvent.ErrorMessage); Assert.Equal("2026-06-14 09:05:00", webhookEvent.DoneDate); } + + [Fact] + public void VerifyAcceptsValidSignature() + { + const string payload = """{"type":"delivery_report","message_id":"msg-123"}"""; + const string secretKey = "webhook-secret"; + var signature = Sign(payload, secretKey); + + var verified = TermiiWebhookSignature.Verify(payload, signature, secretKey); + + Assert.True(verified); + } + + [Fact] + public void VerifyAcceptsSha512PrefixedSignature() + { + const string payload = """{"type":"delivery_report","message_id":"msg-123"}"""; + const string secretKey = "webhook-secret"; + var signature = $"sha512={Sign(payload, secretKey)}"; + + var verified = TermiiWebhookSignature.Verify(payload, signature, secretKey); + + Assert.True(verified); + } + + [Fact] + public void VerifyRejectsAlteredPayload() + { + const string payload = """{"type":"delivery_report","message_id":"msg-123"}"""; + const string secretKey = "webhook-secret"; + var signature = Sign(payload, secretKey); + + var verified = TermiiWebhookSignature.Verify( + """{"type":"delivery_report","message_id":"msg-456"}""", + signature, + secretKey); + + Assert.False(verified); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-signature")] + [InlineData("sha512=not-a-signature")] + public void VerifyRejectsMissingOrMalformedSignature(string? signature) + { + var verified = TermiiWebhookSignature.Verify( + """{"type":"delivery_report"}""", + signature, + "webhook-secret"); + + Assert.False(verified); + } + + [Fact] + public void VerifyAcceptsRawPayloadBytes() + { + const string payload = """{"type":"delivery_report","message_id":"msg-123"}"""; + const string secretKey = "webhook-secret"; + var signature = Sign(payload, secretKey); + + var verified = TermiiWebhookSignature.Verify( + Encoding.UTF8.GetBytes(payload), + signature, + secretKey); + + Assert.True(verified); + } + + private static string Sign(string payload, string secretKey) + { + using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + var builder = new StringBuilder(hash.Length * 2); + + foreach (var value in hash) + { + builder.Append(value.ToString("x2")); + } + + return builder.ToString(); + } }