From 8857369ea9d4d35885c4b2e9d1a1c34e55334907 Mon Sep 17 00:00:00 2001 From: Szymon Wlodarski Date: Fri, 31 Jan 2025 13:30:55 +0100 Subject: [PATCH] SP-1022 - C# Kiosk Demo: Add HMAC verification --- .../AbstractUiIntegrationTest.cs | 12 +++- .../UpdateInvoiceIntegrationTest.cs | 54 +++++++++++++-- .../Tasks/UpdateInvoice/WebhookVerifier.cs | 25 +++++++ .../Ui/UpdateInvoice/HttpUpdateInvoice.cs | 67 +++++++++++++++---- .../DependencyInjectionConfiguration.cs | 3 +- .../Src/Shared/Logger/LogCode.cs | 5 +- 6 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Features/Tasks/UpdateInvoice/WebhookVerifier.cs diff --git a/CsharpKioskDemoDotnet.IntegrationTests/AbstractUiIntegrationTest.cs b/CsharpKioskDemoDotnet.IntegrationTests/AbstractUiIntegrationTest.cs index 52505f0..4870767 100644 --- a/CsharpKioskDemoDotnet.IntegrationTests/AbstractUiIntegrationTest.cs +++ b/CsharpKioskDemoDotnet.IntegrationTests/AbstractUiIntegrationTest.cs @@ -69,7 +69,8 @@ protected Task Get(string url) protected Task Post( string url, - string jsonRequest + string jsonRequest, + Dictionary? headers = null ) { var httpContent = new StringContent( @@ -78,6 +79,13 @@ string jsonRequest "application/json" ); + if (headers != null) { + foreach(var item in headers) + { + httpContent.Headers.Add(item.Key, item.Value); + } + } + return _client.PostAsync(url, httpContent); } @@ -106,4 +114,4 @@ public void Info(LogCode code, string message, Dictionary conte public void Error(LogCode code, string message, Dictionary context) { } -} \ No newline at end of file +} diff --git a/CsharpKioskDemoDotnet.IntegrationTests/Src/Invoice/Infrastructure/Ui/UpdateInvoice/UpdateInvoiceIntegrationTest.cs b/CsharpKioskDemoDotnet.IntegrationTests/Src/Invoice/Infrastructure/Ui/UpdateInvoice/UpdateInvoiceIntegrationTest.cs index 322bb55..1f04011 100644 --- a/CsharpKioskDemoDotnet.IntegrationTests/Src/Invoice/Infrastructure/Ui/UpdateInvoice/UpdateInvoiceIntegrationTest.cs +++ b/CsharpKioskDemoDotnet.IntegrationTests/Src/Invoice/Infrastructure/Ui/UpdateInvoice/UpdateInvoiceIntegrationTest.cs @@ -19,7 +19,14 @@ public async Task POST_InvoiceExistsForUuidAndUpdateDataAreValid_UpdateInvoice() var updateDataJson = UnitTest.GetDataFromFile("updateData.json"); // when - var result = await Post("/invoices/" + invoice.Uuid, updateDataJson); + var result = await Post( + "/invoices/" + invoice.Uuid, + updateDataJson, + new Dictionary + { + { "x-signature", "bKGK0WgsFfMSEg4fpik9+OdjYrYNA1E99kI1QJmbfKw=" } + } + ); // then result.EnsureSuccessStatusCode(); @@ -37,7 +44,14 @@ public async Task POST_InvoiceDoesNotExistsForUuid_DoNotUpdateInvoice() var updateDataJson = UnitTest.GetDataFromFile("updateData.json"); // when - var result = await Post("/invoices/12312412", updateDataJson); + var result = await Post( + "/invoices/12312412", + updateDataJson, + new Dictionary + { + { "x-signature", "bKGK0WgsFfMSEg4fpik9+OdjYrYNA1E99kI1QJmbfKw=" } + } + ); // then UnitTest.Equals( @@ -58,7 +72,14 @@ public async Task POST_UpdateDataAreInvalid_DoNotUpdateInvoice() var updateDataJson = UnitTest.GetDataFromFile("invalidUpdateData.json"); // when - var result = await Post("/invoices/" + invoice.Uuid, updateDataJson); + var result = await Post( + "/invoices/" + invoice.Uuid, + updateDataJson, + new Dictionary + { + { "x-signature", "16imUAXdJqur7yyQyDRRfcbPCeMPiuBFnNJVLlpi3hQ=" } + } + ); // then UnitTest.Equals( @@ -75,6 +96,31 @@ public async Task POST_UpdateDataAreInvalid_DoNotUpdateInvoice() ); } + [Fact] + public async Task POST_WebhookSignatureInvalid_DoNotUpdateInvoice() + { + // given + var invoice = CreateInvoice(); + var updateDataJson = UnitTest.GetDataFromFile("invalidUpdateData.json"); + + // when + var result = await Post( + "/invoices/" + invoice.Uuid, + updateDataJson, + new Dictionary + { + { "x-signature", "randomsignature" } + } + ); + + // then + result.EnsureSuccessStatusCode(); + UnitTest.Equals( + "new", + GetInvoiceRepository().FindById(invoice.Id).Status + ); + } + private CsharpKioskDemoDotnet.Invoice.Domain.Invoice CreateInvoice() { var invoiceJson = UnitTest.GetDataFromFile("invoice.json"); @@ -83,4 +129,4 @@ private CsharpKioskDemoDotnet.Invoice.Domain.Invoice CreateInvoice() return invoice; } -} \ No newline at end of file +} diff --git a/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Features/Tasks/UpdateInvoice/WebhookVerifier.cs b/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Features/Tasks/UpdateInvoice/WebhookVerifier.cs new file mode 100644 index 0000000..b293eee --- /dev/null +++ b/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Features/Tasks/UpdateInvoice/WebhookVerifier.cs @@ -0,0 +1,25 @@ +// Copyright 2023 BitPay. +// All rights reserved. + +using System; +using System.Security.Cryptography; +using System.Text; + +namespace CsharpKioskDemoDotnet.Invoice.Infrastructure.Features.Tasks.UpdateInvoice; + +public class WebhookVerifier +{ + public bool Verify(string signingKey, string sigHeader, string webhookBody) + { + ArgumentNullException.ThrowIfNull(signingKey); + ArgumentNullException.ThrowIfNull(sigHeader); + ArgumentNullException.ThrowIfNull(webhookBody); + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey)); + byte[] signatureBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(webhookBody)); + string calculated = Convert.ToBase64String(signatureBytes); + bool match = sigHeader.Equals(calculated, StringComparison.Ordinal); + + return match; + } +} diff --git a/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Ui/UpdateInvoice/HttpUpdateInvoice.cs b/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Ui/UpdateInvoice/HttpUpdateInvoice.cs index 569a999..6111cea 100644 --- a/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Ui/UpdateInvoice/HttpUpdateInvoice.cs +++ b/CsharpKioskDemoDotnet/Src/Invoice/Infrastructure/Ui/UpdateInvoice/HttpUpdateInvoice.cs @@ -3,9 +3,16 @@ using CsharpKioskDemoDotnet.Invoice.Application.Features.Tasks.UpdateInvoice; using CsharpKioskDemoDotnet.Invoice.Domain; +using CsharpKioskDemoDotnet.Invoice.Infrastructure.Features.Tasks.UpdateInvoice; using CsharpKioskDemoDotnet.Shared; +using CsharpKioskDemoDotnet.Shared.Infrastructure; +using CsharpKioskDemoDotnet.Shared.Logger; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using System.Text; + +using ILogger = CsharpKioskDemoDotnet.Shared.Logger.ILogger; namespace CsharpKioskDemoDotnet.Invoice.Infrastructure.Ui.UpdateInvoice; @@ -13,39 +20,71 @@ public class HttpUpdateInvoice : Controller { private readonly Application.Features.Tasks.UpdateInvoice.UpdateInvoice _updateInvoice; private readonly IJsonToObjectConverter _jsonToObjectConverter; + private readonly WebhookVerifier _webhookVerifier; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; public HttpUpdateInvoice( Application.Features.Tasks.UpdateInvoice.UpdateInvoice updateInvoice, - IJsonToObjectConverter jsonToObjectConverter + IJsonToObjectConverter jsonToObjectConverter, + WebhookVerifier webhookVerifier, + IConfiguration configuration, + ILogger logger ) { _updateInvoice = updateInvoice; _jsonToObjectConverter = jsonToObjectConverter; + _webhookVerifier = webhookVerifier; + _configuration = configuration; + _logger = logger; } // POST: invoice/{uuid} [HttpPost("invoices/{uuid}")] public ActionResult Execute( string uuid, - [FromBody] Dictionary body + [FromHeader(Name = "x-signature")] string signature ) { + var token = _configuration["BitPay:Token"]; + using StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8); + var rawBody = reader.ReadToEndAsync().GetAwaiter().GetResult(); + ArgumentNullException.ThrowIfNull(uuid); + ArgumentNullException.ThrowIfNull(rawBody); + ArgumentNullException.ThrowIfNull(token); + + var body = JsonConvert.DeserializeObject>(rawBody); + ArgumentNullException.ThrowIfNull(body); - try - { - _updateInvoice.Execute(uuid, GetData(body)); + + if (_webhookVerifier.Verify(token, signature, rawBody)) { + try + { + _updateInvoice.Execute(uuid, GetData(body)); + + return Ok(); + } + catch (ValidationInvoiceUpdateDataFailedException exception) + { + return BadRequest(exception.Errors); + } + catch (InvoiceNotFoundException) + { + return NotFound(); + } + } else { + _logger.Error( + LogCode.IpnSignatureVerificationFail, + "Webhook signature verification failed", + new Dictionary + { + { "uuid", uuid } + } + ); return Ok(); } - catch (ValidationInvoiceUpdateDataFailedException exception) - { - return BadRequest(exception.Errors); - } - catch (InvoiceNotFoundException) - { - return NotFound(); - } } private Dictionary GetData(Dictionary body) @@ -65,4 +104,4 @@ [FromBody] Dictionary body return data!; } -} \ No newline at end of file +} diff --git a/CsharpKioskDemoDotnet/Src/Shared/Infrastructure/DependencyInjectionConfiguration.cs b/CsharpKioskDemoDotnet/Src/Shared/Infrastructure/DependencyInjectionConfiguration.cs index 8ac9abe..64449c6 100644 --- a/CsharpKioskDemoDotnet/Src/Shared/Infrastructure/DependencyInjectionConfiguration.cs +++ b/CsharpKioskDemoDotnet/Src/Shared/Infrastructure/DependencyInjectionConfiguration.cs @@ -60,6 +60,7 @@ public static void Execute(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services .AddServerSentEvents( @@ -70,4 +71,4 @@ public static void Execute(WebApplicationBuilder builder) } ); } -} \ No newline at end of file +} diff --git a/CsharpKioskDemoDotnet/Src/Shared/Logger/LogCode.cs b/CsharpKioskDemoDotnet/Src/Shared/Logger/LogCode.cs index dada945..fb84776 100644 --- a/CsharpKioskDemoDotnet/Src/Shared/Logger/LogCode.cs +++ b/CsharpKioskDemoDotnet/Src/Shared/Logger/LogCode.cs @@ -15,7 +15,8 @@ public enum LogCode [Description("INVOICE_UPDATE_FAIL")] InvoiceUpdateFail, [Description("IPN_RECEIVED")] IpnReceived, [Description("IPN_VALIDATE_SUCCESS")] IpnValidateSuccess, - [Description("IPN_VALIDATE_FAIL")] IpnValidateFail + [Description("IPN_VALIDATE_FAIL")] IpnValidateFail, + [Description("IPN_SIGNATURE_VERIFICATION_FAIL")] IpnSignatureVerificationFail } public static class DescriptionAttributeExtensions @@ -29,4 +30,4 @@ public static string GetEnumDescription(this Enum e) return descriptionAttribute!.Description; } -} \ No newline at end of file +}