diff --git a/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs b/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs index 785ccae..60cb2d3 100644 --- a/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs +++ b/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs @@ -3,6 +3,7 @@ // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 using Core.Application.Pipelines; +using Core.Domain; using FluentValidation; using MediatR; @@ -19,6 +20,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddValidatorsFromAssembly(Application.AssemblyReference.Assembly); services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationInjection).Assembly)); + services.AddSingleton(); + return services; } diff --git a/backend/core/src/Core.API/Program.cs b/backend/core/src/Core.API/Program.cs index a8a30e5..76ea524 100644 --- a/backend/core/src/Core.API/Program.cs +++ b/backend/core/src/Core.API/Program.cs @@ -47,6 +47,8 @@ app.UseRouting(); app.UseAuthorization(); +app.UseMiddleware(); + app.MapControllers(); diff --git a/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs new file mode 100644 index 0000000..44ac067 --- /dev/null +++ b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs @@ -0,0 +1,100 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using System.Net; +using System.Security.Claims; +using System.Security.Principal; +using Core.Domain.Exceptions; +using Core.Domain.Repositories; +using Core.Presentation.Models; + +namespace Core.API.ResponseHandling; + +/// +/// Verifies if the public key supplied in the header is linked to the user. +/// The user is determined by the NameIdentifier claim (CustomerCode) in the request. +/// +public class PublicKeyLinkedMiddleware +{ + private readonly ICustomerDeviceRepository _customerDeviceRepository; + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public PublicKeyLinkedMiddleware( + ICustomerDeviceRepository customerDeviceRepository, + RequestDelegate next, + ILogger logger) + { + _customerDeviceRepository = customerDeviceRepository; + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + // skip the middleware for specific endpoints + if (!SkipEndpoint(context)) + { + var userId = GetUserId(context.User); + var pubKey = context.Request.Headers["x-public-key"]; + + // get all public keys linked to the user + var customerDevices = await _customerDeviceRepository.GetAsync(userId, context.RequestAborted); + + // if the user does not have any public keys or the public key is not linked to the user, return forbidden + if (customerDevices is null + || customerDevices.PublicKeys.All(keys => keys.PublicKey != pubKey)) + { + _logger.LogError("Public key not linked to user"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Unknown public-key", "x-public-key")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; + } + } + + // safe to continue + await _next(context); + } + + /// + /// Extract the user id from the claims + /// + private static string GetUserId(IPrincipal user) + { + if (user.Identity is not ClaimsIdentity identity) + { + throw new Exception($"Identity is not of type ClaimsIdentity"); + } + + var claim = identity.FindFirst(ClaimTypes.NameIdentifier); + + return claim is null + ? throw new Exception($"{ClaimTypes.NameIdentifier} not found in claims") + : claim.Value; + } + + /// + /// Skip the middleware for specific endpoints + /// + private static bool SkipEndpoint(HttpContext context) + { + var endpoint = context.GetEndpoint(); + var endpointName = endpoint?.Metadata.GetMetadata()?.EndpointName; + + var excludeList = new[] { "DeviceAuthentication" }; + + return context.Request.Path.StartsWithSegments("/health") + || excludeList.Contains(endpointName); + } + + private static async Task WriteCustomErrors(HttpResponse httpResponse, CustomErrors customErrors, int statusCode) + { + httpResponse.StatusCode = statusCode; + httpResponse.ContentType = "application/json"; + + var response = CustomErrorsResponse.FromCustomErrors(customErrors); + var json = System.Text.Json.JsonSerializer.Serialize(response); + await httpResponse.WriteAsync(json); + } +} diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index 2f42210..f6626f3 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -2,134 +2,149 @@ // under the Apache License, Version 2.0. See the NOTICE file at the root // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 +using Core.Domain; using Core.Domain.Exceptions; using Core.Presentation.Models; -using Newtonsoft.Json.Linq; using NSec.Cryptography; using System.Net; using System.Text; -using static Core.Domain.Constants; namespace Core.API.ResponseHandling { + /// + /// Middleware to verify the signature of incoming requests. + /// It reads the x-signature, x-algorithm, x-public-key and x-timestamp headers from the request. + /// Using the supported algorithm it verifies the signature of the request. + /// + /// A 30 second time difference is allowed between the timestamp in the request and the server time. + /// + /// Checking if the public-key is valid is not done in this middleware. + /// public class SignatureVerificationMiddleware { private readonly RequestDelegate _next; + private readonly IDateTimeProvider _dateTimeProvider; private readonly ILogger _logger; public SignatureVerificationMiddleware( RequestDelegate next, + IDateTimeProvider dateTimeProvider, ILogger logger) { _next = next; + _dateTimeProvider = dateTimeProvider; _logger = logger; } + private enum SignatureAlgorithmHeader + { + ED25519 + } + public async Task Invoke(HttpContext context) { - try + // Retrieve headers from the request + string? signatureHeader = context.Request.Headers["x-signature"]; + string? algorithmHeader = context.Request.Headers["x-algorithm"]; + string? publicKeyHeader = context.Request.Headers["x-public-key"]; + string? timestampHeader = context.Request.Headers["x-timestamp"]; + + // Make sure the headers are present + if (!Enum.TryParse(algorithmHeader, ignoreCase: true, out _)) { - // Retrieve headers from the request - string? signatureHeader = context.Request.Headers["x-signature"]; - string? payloadHeader = context.Request.Headers["x-payload"]; - string? publicKeyHeader = context.Request.Headers["x-public-key"]; - - // Retrieve method from the request - var method = context.Request.Method; - - if (string.IsNullOrWhiteSpace(payloadHeader)) - { - _logger.LogError("Missing payload header"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-payload")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - return; - } - - if (string.IsNullOrWhiteSpace(signatureHeader)) - { - _logger.LogError("Missing signature header"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-signature")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - return; - } - - if (string.IsNullOrWhiteSpace(publicKeyHeader)) - { - _logger.LogError("Missing publicKey header"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-public-key")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - return; - } - - byte[] payloadBytes = Convert.FromBase64String(payloadHeader); - var payloadString = Encoding.UTF8.GetString(payloadBytes); - - JObject payloadJson = JObject.Parse(payloadString); - - byte[] publicKeyBytes = Convert.FromBase64String(publicKeyHeader); - - // Get the current Unix UTC timestamp (rounded to 30 seconds) - long currentTimestamp = (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds; - currentTimestamp = (currentTimestamp / 30) * 30; // Round to the nearest 30 seconds - - // Decode the signature header from Base64 - byte[]? signatureBytes = Convert.FromBase64String(signatureHeader); - - long timestamp = 0; - - // Check if the "timestamp" property is present - if (payloadJson.TryGetValue(SignaturePayload.Timestamp, out var timestampToken)) - { - // Extract the timestamp value - timestamp = (long)timestampToken; - } - else - { - _logger.LogError("Missing timestamp in header"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "timestamp")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - return; - } - - bool isCurrentTime = timestamp == currentTimestamp; - - long allowedDifference = 30; // 30 seconds - bool isWithin30Seconds = Math.Abs(currentTimestamp - timestamp) <= allowedDifference; - - if (isCurrentTime || isWithin30Seconds) - { - if (VerifySignature(publicKeyBytes, payloadBytes, signatureBytes)) - { - await _next(context); // Signature is valid, continue with the request - } - else - { - _logger.LogError("Invalid signature"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - } - } - else - { - _logger.LogError("Timestamp outdated"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "timestamp")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - } + _logger.LogError("Invalid algorithm header"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid Header", "x-algorithm")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; } - catch (CustomErrorsException ex) + + if (string.IsNullOrWhiteSpace(signatureHeader)) { - _logger.LogError(ex, "Unknown exception thrown: {message}", ex.Message); - throw; + _logger.LogError("Missing signature header"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-signature")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; } - catch (Exception ex) + + if (string.IsNullOrWhiteSpace(publicKeyHeader)) { - _logger.LogError(ex, "Unknown exception thrown: {message}", ex.Message); - var customErrors = new CustomErrors(new CustomError("Forbidden", ex.Message, ex.Source!)); + _logger.LogError("Missing publicKey header"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-public-key")); await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; } + + if (string.IsNullOrWhiteSpace(timestampHeader) + || !long.TryParse(timestampHeader, out var timestampHeaderLong)) + { + _logger.LogError("Missing timestamp header"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-timestamp")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; + } + + // Check if the timestamp is within the allowed time + if (!IsWithinAllowedTime(timestampHeaderLong)) + { + _logger.LogError("Timestamp outdated"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "x-timestamp")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; + } + + // TODO: Check if the public key is valid according to algorithm + + var payloadSigningStream = await GetPayloadStream(context, timestampHeader); + + // Parse the public key + var publicKeyBytes = Convert.FromBase64String(publicKeyHeader); + + // Decode the signature header from Base64 + var signatureBytes = Convert.FromBase64String(signatureHeader); + + if (VerifySignature(publicKeyBytes, payloadSigningStream.ToArray(), signatureBytes)) + { + // Signature is valid, continue with the request + await _next(context); + } + else + { + _logger.LogError("Invalid signature"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + } + } + + private static async Task GetPayloadStream(HttpContext context, string timestampHeader) + { + // Leave the body open so the next middleware can read it. + context.Request.EnableBuffering(); + + // Set-up the payload stream to verify the signature + var payloadSigningStream = new MemoryStream(); + + // Copy the timestamp to the payload stream + await payloadSigningStream.WriteAsync(Encoding.UTF8.GetBytes(timestampHeader)); + + // Copy the request body to the payload stream + await context.Request.Body.CopyToAsync(payloadSigningStream); + + // Reset the request body stream position so the next middleware can read it + context.Request.Body.Position = 0; + + return payloadSigningStream; + } + + private bool IsWithinAllowedTime(long timestampHeaderLong) + { + var suppliedDateTime = DateTimeOffset.FromUnixTimeSeconds(timestampHeaderLong); + var dateDiff = _dateTimeProvider.UtcNow - suppliedDateTime; + long allowedDifference = 30; // 30 seconds + return Math.Abs(dateDiff.TotalSeconds) <= allowedDifference; } - private static async Task WriteCustomErrors(HttpResponse httpResponse, CustomErrors customErrors, int statusCode) + private static async Task WriteCustomErrors(HttpResponse httpResponse, CustomErrors customErrors, + int statusCode) { httpResponse.StatusCode = statusCode; httpResponse.ContentType = "application/json"; @@ -161,4 +176,4 @@ public static void ConfigureSignatureVerificationMiddleware(this IApplicationBui app.UseMiddleware(); } } -} +} \ No newline at end of file diff --git a/backend/core/src/Core.Application/Commands/CustomerCommands/DeviceAuthenticationCommand.cs b/backend/core/src/Core.Application/Commands/CustomerCommands/DeviceAuthenticationCommand.cs index a46cb83..c0f6852 100644 --- a/backend/core/src/Core.Application/Commands/CustomerCommands/DeviceAuthenticationCommand.cs +++ b/backend/core/src/Core.Application/Commands/CustomerCommands/DeviceAuthenticationCommand.cs @@ -44,18 +44,23 @@ public async Task Handle(DeviceAuthenticationCommand reque var customerDevice = await _customerDeviceRepository.GetAsync(request.CustomerCode, cancellationToken); + // If the customer does not exist, create a new customer device if (customerDevice is null) { // Customer does not exist, create a new customer device - otpKey = CreateNewCustomerDevice(request); + otpKey = await CreateNewCustomerDevice(request); } + // If the OTPCode is not empty, verify and process the OTPCode else if (!string.IsNullOrWhiteSpace(request.OTPCode)) { - otpKey = VerifyAndProcessOTPCode(request, customerDevice); + otpKey = await VerifyAndProcessOTPCode(request, customerDevice); } + // if the OTPCode is empty and the customer has a public key, then the customer is already verified else if (string.IsNullOrWhiteSpace(request.OTPCode) && !CustomerHasPublicKey(customerDevice, request.PublicKey)) { - throw new CustomErrorsException(DomainErrorCode.ExistingKeyError.ToString(), request.CustomerCode, "Verfication needed."); + // Customer has a public key but no OTPCode, throw an error + // This should start the OTP validation process client-side + throw new CustomErrorsException(DomainErrorCode.ExistingKeyError.ToString(), request.CustomerCode, "Verification needed."); } await _unitOfWork.SaveChangesAsync(cancellationToken); @@ -63,7 +68,7 @@ public async Task Handle(DeviceAuthenticationCommand reque return new DeviceAuthentication { OTPKey = otpKey }; } - private string VerifyAndProcessOTPCode(DeviceAuthenticationCommand request, CustomerOTPKeyStore customerDevice) + private async Task VerifyAndProcessOTPCode(DeviceAuthenticationCommand request, CustomerOTPKeyStore customerDevice) { string otpKey = customerDevice.OTPKey; @@ -81,7 +86,7 @@ private string VerifyAndProcessOTPCode(DeviceAuthenticationCommand request, Cust if (string.IsNullOrWhiteSpace(customerDevice.OTPKey)) { // Generate a new OTPKey if none exists - otpKey = _otpGenerator.GenerateNewOTPKey().Result; + otpKey = await _otpGenerator.GenerateNewOTPKey(); customerDevice.OTPKey = otpKey; } @@ -90,9 +95,9 @@ private string VerifyAndProcessOTPCode(DeviceAuthenticationCommand request, Cust return otpKey; } - private string CreateNewCustomerDevice(DeviceAuthenticationCommand request) + private async Task CreateNewCustomerDevice(DeviceAuthenticationCommand request) { - string otpKey = _otpGenerator.GenerateNewOTPKey().Result; + string otpKey = await _otpGenerator.GenerateNewOTPKey(); // Create a new customer OTP key store and associated device var newDevice = CustomerOTPKeyStore.New(request.CustomerCode, otpKey, request.PublicKey); diff --git a/backend/core/src/Core.Domain/DateTimeProvider.cs b/backend/core/src/Core.Domain/DateTimeProvider.cs index 365635d..340a42e 100644 --- a/backend/core/src/Core.Domain/DateTimeProvider.cs +++ b/backend/core/src/Core.Domain/DateTimeProvider.cs @@ -6,6 +6,28 @@ namespace Core.Domain { + public interface IDateTimeProvider + { + public DateTimeOffset UtcNow { get; } + } + + public class StaticDateTimeProvider : IDateTimeProvider + { + private readonly DateTimeOffset dateTimeOffset; + + public StaticDateTimeProvider(DateTimeOffset dateTimeOffset) + { + this.dateTimeOffset = dateTimeOffset; + } + + public DateTimeOffset UtcNow => dateTimeOffset; + } + + public class SystemDateTimeProvider : IDateTimeProvider + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } + // https://dvoituron.com/2020/01/22/UnitTest-DateTime/ public class DateTimeProvider { diff --git a/backend/core/tests/Core.APITests/Core.APITests.csproj b/backend/core/tests/Core.APITests/Core.APITests.csproj index bdea7bd..9df8940 100644 --- a/backend/core/tests/Core.APITests/Core.APITests.csproj +++ b/backend/core/tests/Core.APITests/Core.APITests.csproj @@ -10,8 +10,10 @@ + + - + diff --git a/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs new file mode 100644 index 0000000..7f8255b --- /dev/null +++ b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs @@ -0,0 +1,55 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain.Abstractions; +using Core.Domain.Entities.CustomerAggregate; +using Core.Domain.Repositories; + +namespace Core.APITests; + +public class FakeCustomerDeviceRepository : ICustomerDeviceRepository +{ + public void Add(CustomerOTPKeyStore entity) + { + throw new NotImplementedException(); + } + + public Task FindAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetAsync(string customerCode, CancellationToken cancellationToken = default) + { + var store = new CustomerOTPKeyStore + { + CustomerCode = customerCode, + PublicKeys = new[] { new CustomerDevicePublicKeys { PublicKey = "VALID-PUBKEY" } } + }; + + return Task.FromResult(store)!; + } + + public Task HasOtpKeyAsync(string customerCode, CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } + + public Task HasPublicKeyAsync(string customerCode, string publicKey, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> ListAsync(ISpecification specification, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public void Update(CustomerOTPKeyStore entity) + { + throw new NotImplementedException(); + } +} diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs new file mode 100644 index 0000000..3223e9a --- /dev/null +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs @@ -0,0 +1,49 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.API.ResponseHandling; +using Core.Domain; +using Core.Domain.Exceptions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using System.Net; + +namespace Core.APITests.ResponseHandlingTests; + +[TestClass] +public class ExceptionMiddlewareTests +{ + /// + /// When we throw a CustomErrorsException with ErrorCode = NotFoundKeyError, we should get a 409 response. + /// This should allow the client to know that it should go for an "add additional device" flow + /// + [TestMethod] + public async Task ExistingKey_Returns_Conflict() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .Configure(app => + { + app.ConfigureCustomExceptionMiddleware(); + + app.Run(context => throw new CustomErrorsException(DomainErrorCode.ExistingKeyError.ToString(), "TEST", "Verification needed.")); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // send request + var response = await client.GetAsync("/"); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("""{"Errors":[{"Code":"ExistingKeyError","Message":"Verification needed.","Target":"TEST"}]}""", responseString); + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + } +} diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs new file mode 100644 index 0000000..c6f6312 --- /dev/null +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs @@ -0,0 +1,118 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.API.ResponseHandling; +using Core.Domain.Repositories; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; + +namespace Core.APITests.ResponseHandlingTests; + +[TestClass] +public class PublicKeyLinkedMiddlewareTests : IDisposable +{ + private IHost host = default!; + private HttpClient client = default!; + + [TestInitialize] + public async Task Init() + { + host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddTransient(); + + // add claims to user identity - this is the user that will be authenticated + services.AddAuthentication("Test") + .AddScheme("Test", op => { }); + services.AddAuthorization(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + + // add the middleware after authentication to have access to the user object and its claims + app.UseMiddleware(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + // map an endpoint that verifies the public key with the authenticated user + endpoints.Map("/verify", async context => + { + await context.Response.WriteAsync($"Hello World {context.User.Identity.Name}!"); + }) + .RequireAuthorization() + .WithName("NotInTheIgnoreList"); + + // map an endpoint that ignores the public key verification (hardcoded list) through the name + endpoints.Map("/ignore", async context => + { + await context.Response.WriteAsync($"Hello World {context.User.Identity.Name}!"); + }) + .RequireAuthorization() + .WithName("DeviceAuthentication"); + }); + }); + }) + .StartAsync(); + + client = host.GetTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test"); + } + + void IDisposable.Dispose() + { + host.Dispose(); + client.Dispose(); + } + + [TestMethod] + public async Task Verify_Returns_Forbidden() + { + var response = await client.GetAsync("/verify"); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Unknown public-key","Target":"x-public-key"}]}""", + responseString); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [TestMethod] + public async Task Verify_Returns_Ok() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/verify"); + request.Headers.Add("x-public-key", "VALID-PUBKEY"); + var response = await client.GetAsync("/verify"); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Unknown public-key","Target":"x-public-key"}]}""", + responseString); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [TestMethod] + public async Task IgnoreEndpoint_Returns_Ok() + { + // send request + var response = await client.GetAsync("/ignore"); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual($"Hello World TestUser!", responseString); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs index fbd1ad5..ac7442f 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs @@ -3,56 +3,166 @@ // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 using Core.API.ResponseHandling; +using Core.Domain; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using NSec.Cryptography; -using System.Security.Cryptography; +using System.Net; +using System.Net.Http.Headers; using System.Text; -namespace Core.APITests.ResponseHandlingTests.SignatureVerificationMiddlewareTests +namespace Core.APITests.ResponseHandlingTests.SignatureVerificationMiddlewareTests; + +[TestClass] +public sealed class SignatureVerificationMiddlewareTests : IDisposable { - [TestClass()] - public class SignatureVerificationMiddlewareTests + IHost host = default!; + HttpClient client = default!; + + [TestInitialize] + public async Task Init() + { + host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton( + new StaticDateTimeProvider( + DateTimeOffset.FromUnixTimeSeconds(1577836800))); + }) + .Configure(app => + { + app.ConfigureSignatureVerificationMiddleware(); + + app.Run(async context => + { + var msg = await context.Request.ReadFromJsonAsync(); + await context.Response.WriteAsync($"Hello World {msg.Message}!"); + }); + }); + }) + .StartAsync(); + + client = host.GetTestClient(); + } + + [TestMethod] + public void VerifySignature_ValidSignature() + { + var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; + var publicKey = Convert.FromBase64String(publicKeyB64); + var privateKeyB64 = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; + var privateKey = Convert.FromBase64String(privateKeyB64); + var payload = Encoding.UTF8.GetBytes("{\"timestamp\": 1700136384437}"); + + var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); + + var signature = SignatureAlgorithm.Ed25519.Sign(key, payload); + + // Act + bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, payload, signature); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void VerifySignature_InvalidSignature() { - [TestMethod] - public void VerifySignature_ValidSignature_ReturnsTrue() - { - var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; - var publicKey = Convert.FromBase64String(publicKeyB64); - var privateKeyB64 = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; - var privateKey = Convert.FromBase64String(privateKeyB64); - var payload = Encoding.UTF8.GetBytes("{\"timestamp\": 1700136384437}"); + var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; + var publicKey = Convert.FromBase64String(publicKeyB64); + var privateKeyB64 = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; + var privateKey = Convert.FromBase64String(privateKeyB64); + var payload = Encoding.UTF8.GetBytes("{\"timestamp\": 1700136384437}"); - var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); + var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); - var signature = SignatureAlgorithm.Ed25519.Sign(key, payload); + var validSignature = SignatureAlgorithm.Ed25519.Sign(key, payload); - // Act - bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, payload, signature); + // Modifying the payload to create an invalid signature + byte[] modifiedPayload = Encoding.UTF8.GetBytes("{\"timestamp\": 17001363844}"); - // Assert - Assert.IsTrue(result); - } + // Act + bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, modifiedPayload, validSignature); - [TestMethod] - public void VerifySignature_InvalidSignature_ReturnsFalse() - { - var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; - var publicKey = Convert.FromBase64String(publicKeyB64); - var privateKeyB64 = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; - var privateKey = Convert.FromBase64String(privateKeyB64); - var payload = Encoding.UTF8.GetBytes("{\"timestamp\": 1700136384437}"); + // Assert + Assert.IsFalse(result); + } - var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); + public record MessageObject(string Message); - var validSignature = SignatureAlgorithm.Ed25519.Sign(key, payload); + [TestMethod] + public async Task VerifySignatureMiddleware_ValidSignature() + { + // create get request + var request = new HttpRequestMessage(HttpMethod.Post, "/"); + request.Content = new StringContent("{\"message\":\"HELLO\"}"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + request.Headers.Add("x-algorithm", "ED25519"); + request.Headers.Add("x-timestamp", "1577836800"); + request.Headers.Add("x-signature", "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ=="); + request.Headers.Add("x-public-key", "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); - // Modifying the payload to create an invalid signature - byte[] modifiedPayload = Encoding.UTF8.GetBytes("{\"timestamp\": 17001363844}"); + // send request + var response = await client.SendAsync(request); - // Act - bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, modifiedPayload, validSignature); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); - // Assert - Assert.IsFalse(result); - } + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("Hello World HELLO!", responseString); + } + + [TestMethod] + public async Task VerifySignatureMiddleware_InvalidSignature() + { + // create get request + var request = new HttpRequestMessage(HttpMethod.Post, "/"); + request.Content = new StringContent("{\"message\":\"HELLO\"}"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + request.Headers.Add("x-algorithm", "ED25519"); + request.Headers.Add("x-timestamp", "1577836801"); + request.Headers.Add("x-signature", "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ=="); + request.Headers.Add("x-public-key", "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); + + // send request + var response = await client.SendAsync(request); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Invalid signature","Target":"x-signature"}]}""", responseString); + } + + [TestMethod] + public async Task VerifySignatureMiddleware_InvalidAlgorithm() + { + // create get request + var request = new HttpRequestMessage(HttpMethod.Post, "/"); + request.Content = new StringContent("{\"message\":\"HELLO\"}"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + request.Headers.Add("x-algorithm", "RSA"); + request.Headers.Add("x-timestamp", "1577836801"); + request.Headers.Add("x-signature", "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ=="); + request.Headers.Add("x-public-key", "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); + + // send request + var response = await client.SendAsync(request); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Invalid Header","Target":"x-algorithm"}]}""", responseString); + } + + public void Dispose() + { + host.Dispose(); } } \ No newline at end of file diff --git a/backend/core/tests/Core.APITests/TestAuthHandler.cs b/backend/core/tests/Core.APITests/TestAuthHandler.cs new file mode 100644 index 0000000..ac1a640 --- /dev/null +++ b/backend/core/tests/Core.APITests/TestAuthHandler.cs @@ -0,0 +1,35 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Core.APITests.ResponseHandlingTests; + +public sealed class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler(IOptionsMonitor options, ILoggerFactory logger, + UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new List + { + new(ClaimTypes.Name, "TestUser"), + new(ClaimTypes.NameIdentifier, "TestUser") + }; + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} diff --git a/mobile/src/utils/__tests__/authentication.test.ts b/mobile/src/utils/__tests__/authentication.test.ts index 7c28544..fa05834 100644 --- a/mobile/src/utils/__tests__/authentication.test.ts +++ b/mobile/src/utils/__tests__/authentication.test.ts @@ -4,12 +4,14 @@ import * as ed from "@noble/ed25519"; import { sha512 } from "@noble/hashes/sha512"; +import { getSignatureHeaders } from "../axios"; +import { Buffer } from "buffer"; ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); -//ed.etc.sha512Async = (...m) => Promise.resolve(ed.etc.sha512Sync(...m)); +ed.etc.sha512Async = (...m) => Promise.resolve(ed.etc.sha512Sync(...m)); describe("signMessage noble", () => { - it("message is deterministic", async () => { + it("signature and key generation is deterministic", () => { //var privKey = ed.utils.randomPrivateKey(); const privKey = @@ -18,17 +20,24 @@ describe("signMessage noble", () => { const privateKey = Buffer.from(privKey, "hex"); const privateKeyHex = privateKey.toString("hex"); const privateKeyB64 = privateKey.toString("base64"); + + const hexResult = ed.getPublicKey(privateKey); + const pubKeyB64 = Buffer.from(hexResult).toString("base64"); + expect(privateKeyHex).toEqual( "6e2dd227481f9d65e92f7be424876015eb54e59826206098acbc442fd371dab4" ); expect(privateKeyB64).toEqual( "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ=" ); + expect(pubKeyB64).toEqual("gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); + }); - const hexResult = ed.getPublicKey(privateKey); - const pubKeyB64 = Buffer.from(hexResult).toString("base64"); + it("signature is deterministic", () => { + const privKey = + "6e2dd227481f9d65e92f7be424876015eb54e59826206098acbc442fd371dab4"; - expect(pubKeyB64).toEqual("gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); + const privateKey = Buffer.from(privKey, "hex"); const message = "HELLO"; const messageBytes = Buffer.from(message, "utf-8"); @@ -39,3 +48,24 @@ describe("signMessage noble", () => { ); }); }); + +describe("send signature to server", () => { + + it("message is deterministic", async () => { + const date = new Date("2020-01-01T00:00:00Z"); + + const privKey = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; + + const data = { message: "HELLO" }; + + // we supply the date directly to the function + // trying to mock it using fakeTimers and setSystemTime did not work + // it was unable to resolve the hook (whatever hook that might be) at the end of the call + const sigData = getSignatureHeaders(date, data, privKey); + + expect(sigData.signature).toEqual( + "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ==" + ); + expect(sigData.timestamp).toEqual("1577836800"); + }); +}); diff --git a/mobile/src/utils/axios.ts b/mobile/src/utils/axios.ts index 46dfb57..eefd807 100644 --- a/mobile/src/utils/axios.ts +++ b/mobile/src/utils/axios.ts @@ -9,7 +9,7 @@ import { AuthService } from "../auth/authService"; import * as SecureStore from "expo-secure-store"; import * as ed from "@noble/ed25519"; import { sha512 } from "@noble/hashes/sha512"; -import { fromByteArray, btoa, toByteArray } from "react-native-quick-base64"; +import { fromByteArray, toByteArray } from "react-native-quick-base64"; import { Buffer } from "buffer"; ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); @@ -59,43 +59,40 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { config.headers["Authorization"] = `Bearer ${accessToken}`; if (pubKeyFromStore !== null && privKeyFromStore != null) { + const sigData = getSignatureHeaders(new Date(), config.data, privKeyFromStore); config.headers["x-public-key"] = pubKeyFromStore; + config.headers["x-timestamp"] = sigData.timestamp; + config.headers["x-signature"] = sigData.signature; + config.headers["x-algorithm"] = "ED25519"; + } + } + } - const timestampInSeconds = Math.floor(Date.now() / 1000); // Convert current time to Unix timestamp in seconds - - const payload: { - timestamp: number; - postPayload?: unknown; - } = { - timestamp: timestampInSeconds, - }; - - // hash POST payload if available - if (config.method === "post") { - payload.postPayload = config.data; - } - - const jsonPayload = JSON.stringify(payload); - // base64 encode payload - const base64Payload = btoa(jsonPayload); - - config.headers["x-payload"] = base64Payload; + return config; +} - const privKey = toByteArray(privKeyFromStore); - const privKeyHex = ed.etc.bytesToHex(privKey); +// the date is supplied as a parameter to allow for testing +// there were various issues with trying to mock it directly +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getSignatureHeaders(date: Date, data: any, privKeyFromStore: string) { + const timestampInSeconds = Math.floor(date.getTime() / 1000).toString(); // Convert current time to Unix timestamp in seconds + const dataToSign = data + ? timestampInSeconds + JSON.stringify(data) + : timestampInSeconds; + const bytesToSign = Buffer.from(dataToSign, "utf-8"); - const utfDecodedPayload = Buffer.from(jsonPayload, "utf-8"); + const privKey = toByteArray(privKeyFromStore); + const privKeyHex = ed.etc.bytesToHex(privKey); - const hash = ed.sign(utfDecodedPayload, privKeyHex); + const hash = ed.sign(bytesToSign, privKeyHex); - // Encode the signature in Base64 format - const base64Signature = fromByteArray(hash); - config.headers["x-signature"] = base64Signature; - } - } - } + // Encode the signature in Base64 format + const base64Signature = fromByteArray(hash); - return config; + return { + timestamp: timestampInSeconds, + signature: base64Signature, + }; } async function responseInterceptor(response: AxiosResponse) {