From 5d0dc1cdbf6f48ebb89c714a56fbb0ca7d6d52f8 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Wed, 14 Feb 2024 23:59:44 +0100 Subject: [PATCH 01/12] Rework and simplify auth comm --- .../SignatureVerificationMiddleware.cs | 101 ++++++++---------- mobile/src/utils/axios.ts | 27 ++--- 2 files changed, 55 insertions(+), 73 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index 2f42210..0509e28 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -4,11 +4,9 @@ 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 { @@ -31,20 +29,10 @@ public async Task Invoke(HttpContext context) { // 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"]; + string? timestampHeader = context.Request.Headers["x-timestamp"]; - // 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; - } - + // Make sure the headers are present if (string.IsNullOrWhiteSpace(signatureHeader)) { _logger.LogError("Missing signature header"); @@ -61,58 +49,40 @@ public async Task Invoke(HttpContext context) 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)) + if (string.IsNullOrWhiteSpace(timestampHeader) + || !long.TryParse(timestampHeader, out var timestampHeaderLong)) { - // Extract the timestamp value - timestamp = (long)timestampToken; + _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; } - else + + // Check if the timestamp is within the allowed time + if (!IsWithinAllowedTime(timestampHeaderLong)) { - _logger.LogError("Missing timestamp in header"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "timestamp")); + _logger.LogError("Timestamp outdated"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "timestamp")); await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - return; } - bool isCurrentTime = timestamp == currentTimestamp; + var payloadSigningStream = await GetPayloadStream(context, timestampHeader); - long allowedDifference = 30; // 30 seconds - bool isWithin30Seconds = Math.Abs(currentTimestamp - timestamp) <= allowedDifference; + // Parse the public key + var publicKeyBytes = Convert.FromBase64String(publicKeyHeader); - if (isCurrentTime || isWithin30Seconds) + // Decode the signature header from Base64 + var signatureBytes = Convert.FromBase64String(signatureHeader); + + if (VerifySignature(publicKeyBytes, payloadSigningStream.ToArray(), signatureBytes)) { - 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); - } + // Signature is valid, continue with the request + await _next(context); } else { - _logger.LogError("Timestamp outdated"); - var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "timestamp")); + _logger.LogError("Invalid signature"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature")); await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); } } @@ -129,6 +99,29 @@ public async Task Invoke(HttpContext context) } } + private static async Task GetPayloadStream(HttpContext context, string? timestampHeader) + { + // 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 stream position to the beginning + context.Request.Body.Position = 0; + return payloadSigningStream; + } + + private static bool IsWithinAllowedTime(long timestampHeaderLong) + { + var suppliedDateTimec = DateTimeOffset.FromUnixTimeSeconds(timestampHeaderLong); + var dateDiff = DateTimeOffset.UtcNow - suppliedDateTimec; + long allowedDifference = 30; // 30 seconds + return Math.Abs(dateDiff.TotalSeconds) <= allowedDifference; + } + private static async Task WriteCustomErrors(HttpResponse httpResponse, CustomErrors customErrors, int statusCode) { httpResponse.StatusCode = statusCode; diff --git a/mobile/src/utils/axios.ts b/mobile/src/utils/axios.ts index 46dfb57..80b467b 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)); @@ -61,32 +61,21 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { if (pubKeyFromStore !== null && privKeyFromStore != null) { config.headers["x-public-key"] = pubKeyFromStore; - const timestampInSeconds = Math.floor(Date.now() / 1000); // Convert current time to Unix timestamp in seconds + const timestampInSeconds = Math.floor(Date.now() / 1000).toString(); // Convert current time to Unix timestamp in seconds - const payload: { - timestamp: number; - postPayload?: unknown; - } = { - timestamp: timestampInSeconds, - }; + config.headers["x-timestamp"] = timestampInSeconds; + + const bytesToSign = Buffer.from(timestampInSeconds, "utf-8"); // hash POST payload if available - if (config.method === "post") { - payload.postPayload = config.data; + if (config.data) { + bytesToSign.write(config.data); } - const jsonPayload = JSON.stringify(payload); - // base64 encode payload - const base64Payload = btoa(jsonPayload); - - config.headers["x-payload"] = base64Payload; - const privKey = toByteArray(privKeyFromStore); const privKeyHex = ed.etc.bytesToHex(privKey); - const utfDecodedPayload = Buffer.from(jsonPayload, "utf-8"); - - const hash = ed.sign(utfDecodedPayload, privKeyHex); + const hash = ed.sign(bytesToSign, privKeyHex); // Encode the signature in Base64 format const base64Signature = fromByteArray(hash); From efac0d586c474818a952db705c0bdb83145b341b Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Thu, 15 Feb 2024 20:14:59 +0100 Subject: [PATCH 02/12] Fix and test reworking auth comm --- .../SignatureVerificationMiddleware.cs | 9 +++- .../tests/Core.APITests/Core.APITests.csproj | 1 + .../SignatureVerificationMiddlewareTests.cs | 52 ++++++++++++++++++- .../utils/__tests__/authentication.test.ts | 18 +++++++ mobile/src/utils/axios.ts | 43 ++++++++------- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index 0509e28..32560ba 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -99,18 +99,23 @@ public async Task Invoke(HttpContext context) } } - private static async Task GetPayloadStream(HttpContext context, string? timestampHeader) + 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 stream position to the beginning + // Reset the request body stream position so the next middleware can read it context.Request.Body.Position = 0; + return payloadSigningStream; } diff --git a/backend/core/tests/Core.APITests/Core.APITests.csproj b/backend/core/tests/Core.APITests/Core.APITests.csproj index 240b8bf..f500252 100644 --- a/backend/core/tests/Core.APITests/Core.APITests.csproj +++ b/backend/core/tests/Core.APITests/Core.APITests.csproj @@ -11,6 +11,7 @@ + diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs index fbd1ad5..4356dbd 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs @@ -3,13 +3,19 @@ // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 using Core.API.ResponseHandling; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +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 { - [TestClass()] + [TestClass] public class SignatureVerificationMiddlewareTests { [TestMethod] @@ -54,5 +60,47 @@ public void VerifySignature_InvalidSignature_ReturnsFalse() // Assert Assert.IsFalse(result); } + + public record MessageObject(string Message); + + [TestMethod] + public async Task MiddlewareTest_ReturnsNotFoundForRequest() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .Configure(app => + { + app.UseMiddleware(); + + app.Run(async context => + { + var msg = await context.Request.ReadFromJsonAsync(); + await context.Response.WriteAsync($"Hello World {msg.Message}!"); + }); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // 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-timestamp", "1577836800"); + 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.OK, response.StatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("Hello World HELLO!", responseString); + } } } \ No newline at end of file diff --git a/mobile/src/utils/__tests__/authentication.test.ts b/mobile/src/utils/__tests__/authentication.test.ts index 7c28544..95a86e1 100644 --- a/mobile/src/utils/__tests__/authentication.test.ts +++ b/mobile/src/utils/__tests__/authentication.test.ts @@ -4,10 +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)); +jest.useFakeTimers().setSystemTime(new Date("2020-01-01T00:00:00Z")); + describe("signMessage noble", () => { it("message is deterministic", async () => { //var privKey = ed.utils.randomPrivateKey(); @@ -39,3 +43,17 @@ describe("signMessage noble", () => { ); }); }); + +describe("send signature to server", () => { + it("message is deterministic", async () => { + const privKey = "bi3SJ0gfnWXpL3vkJIdgFetU5ZgmIGCYrLxEL9Nx2rQ="; + + const data = { message: "HELLO" }; + const sigData = getSignatureHeaders(data, privKey); + + expect(sigData.signature).toEqual( + "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ==" + ); + expect(sigData.timestamp).toEqual("1577836800"); + }, 10000); +}); diff --git a/mobile/src/utils/axios.ts b/mobile/src/utils/axios.ts index 80b467b..d1ebbd5 100644 --- a/mobile/src/utils/axios.ts +++ b/mobile/src/utils/axios.ts @@ -59,32 +59,37 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { config.headers["Authorization"] = `Bearer ${accessToken}`; if (pubKeyFromStore !== null && privKeyFromStore != null) { + const sigData = getSignatureHeaders(config.data, privKeyFromStore); config.headers["x-public-key"] = pubKeyFromStore; + config.headers["x-timestamp"] = sigData.timestamp; + config.headers["x-signature"] = sigData.signature; + } + } + } - const timestampInSeconds = Math.floor(Date.now() / 1000).toString(); // Convert current time to Unix timestamp in seconds - - config.headers["x-timestamp"] = timestampInSeconds; - - const bytesToSign = Buffer.from(timestampInSeconds, "utf-8"); + return config; +} - // hash POST payload if available - if (config.data) { - bytesToSign.write(config.data); - } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getSignatureHeaders(data: any, privKeyFromStore: string) { + const timestampInSeconds = Math.floor(Date.now() / 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 privKey = toByteArray(privKeyFromStore); - const privKeyHex = ed.etc.bytesToHex(privKey); + const privKey = toByteArray(privKeyFromStore); + const privKeyHex = ed.etc.bytesToHex(privKey); - const hash = ed.sign(bytesToSign, 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) { From 1b5fbcf0b43d904acdb695f44118f8fde70a8604 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Thu, 15 Feb 2024 21:41:45 +0100 Subject: [PATCH 03/12] Improve tests --- .../ApplicationInjection.cs | 3 + .../SignatureVerificationMiddleware.cs | 125 ++++++++---------- .../core/src/Core.Domain/DateTimeProvider.cs | 22 +++ .../SignatureVerificationMiddlewareTests.cs | 56 +++++++- 4 files changed, 138 insertions(+), 68 deletions(-) diff --git a/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs b/backend/core/src/Core.API/DependencyInjection/ApplicationInjection.cs index 130f6c2..affd2ab 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(Application.AssemblyReference.Assembly); + services.AddSingleton(); + return services; } diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index 32560ba..cb2c1ac 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -2,6 +2,7 @@ // 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 NSec.Cryptography; @@ -13,88 +14,78 @@ namespace Core.API.ResponseHandling 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; + this._dateTimeProvider = dateTimeProvider; _logger = logger; } public async Task Invoke(HttpContext context) { - try + // Retrieve headers from the request + string? signatureHeader = context.Request.Headers["x-signature"]; + string? publicKeyHeader = context.Request.Headers["x-public-key"]; + string? timestampHeader = context.Request.Headers["x-timestamp"]; + + // Make sure the headers are present + 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)) { - // Retrieve headers from the request - string? signatureHeader = context.Request.Headers["x-signature"]; - string? publicKeyHeader = context.Request.Headers["x-public-key"]; - string? timestampHeader = context.Request.Headers["x-timestamp"]; - - // Make sure the headers are present - 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; - } - - 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", "timestamp")); - await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); - } - - 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); - } + _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; } - catch (CustomErrorsException ex) + + 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", "timestamp")); + await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); + return; + } + + 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)) { - _logger.LogError(ex, "Unknown exception thrown: {message}", ex.Message); - throw; + // Signature is valid, continue with the request + await _next(context); } - catch (Exception ex) + else { - _logger.LogError(ex, "Unknown exception thrown: {message}", ex.Message); - var customErrors = new CustomErrors(new CustomError("Forbidden", ex.Message, ex.Source!)); + _logger.LogError("Invalid signature"); + var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature")); await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden); } } @@ -119,10 +110,10 @@ private static async Task GetPayloadStream(HttpContext context, st return payloadSigningStream; } - private static bool IsWithinAllowedTime(long timestampHeaderLong) + private bool IsWithinAllowedTime(long timestampHeaderLong) { var suppliedDateTimec = DateTimeOffset.FromUnixTimeSeconds(timestampHeaderLong); - var dateDiff = DateTimeOffset.UtcNow - suppliedDateTimec; + var dateDiff = _dateTimeProvider.UtcNow - suppliedDateTimec; long allowedDifference = 30; // 30 seconds return Math.Abs(dateDiff.TotalSeconds) <= allowedDifference; } 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/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs index 4356dbd..8201c09 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs @@ -3,10 +3,12 @@ // 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.Net; @@ -64,13 +66,19 @@ public void VerifySignature_InvalidSignature_ReturnsFalse() public record MessageObject(string Message); [TestMethod] - public async Task MiddlewareTest_ReturnsNotFoundForRequest() + public async Task VerifySignatureMiddleware_Successful_HttpProcessing() { using var host = await new HostBuilder() .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton( + new StaticDateTimeProvider( + DateTimeOffset.FromUnixTimeSeconds(1577836800))); + }) .Configure(app => { app.UseMiddleware(); @@ -102,5 +110,51 @@ public async Task MiddlewareTest_ReturnsNotFoundForRequest() var responseString = await response.Content.ReadAsStringAsync(); Assert.AreEqual("Hello World HELLO!", responseString); } + + [TestMethod] + public async Task VerifySignatureMiddleware_Failed() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton( + new StaticDateTimeProvider( + DateTimeOffset.FromUnixTimeSeconds(1577836800))); + }) + .Configure(app => + { + app.UseMiddleware(); + + app.Run(async context => + { + var msg = await context.Request.ReadFromJsonAsync(); + await context.Response.WriteAsync($"Hello World {msg.Message}!"); + }); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // 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-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); + } } } \ No newline at end of file From af3c73986d7a3bcd384da873d0b07b3f003b3dec Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Fri, 16 Feb 2024 14:00:24 +0100 Subject: [PATCH 04/12] Improve tests and small refactors --- .../SignatureVerificationMiddleware.cs | 7 +- .../DeviceAuthenticationCommand.cs | 19 +- .../tests/Core.APITests/Core.APITests.csproj | 20 +- .../ExceptionMiddlewareTests.cs | 49 ++++ .../SignatureVerificationMiddlewareTests.cs | 259 +++++++++--------- 5 files changed, 204 insertions(+), 150 deletions(-) create mode 100644 backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index cb2c1ac..d558e1f 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -23,7 +23,7 @@ public SignatureVerificationMiddleware( ILogger logger) { _next = next; - this._dateTimeProvider = dateTimeProvider; + _dateTimeProvider = dateTimeProvider; _logger = logger; } @@ -31,6 +31,7 @@ public async Task Invoke(HttpContext context) { // 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"]; @@ -112,8 +113,8 @@ private static async Task GetPayloadStream(HttpContext context, st private bool IsWithinAllowedTime(long timestampHeaderLong) { - var suppliedDateTimec = DateTimeOffset.FromUnixTimeSeconds(timestampHeaderLong); - var dateDiff = _dateTimeProvider.UtcNow - suppliedDateTimec; + var suppliedDateTime = DateTimeOffset.FromUnixTimeSeconds(timestampHeaderLong); + var dateDiff = _dateTimeProvider.UtcNow - suppliedDateTime; long allowedDifference = 30; // 30 seconds return Math.Abs(dateDiff.TotalSeconds) <= allowedDifference; } 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/tests/Core.APITests/Core.APITests.csproj b/backend/core/tests/Core.APITests/Core.APITests.csproj index f500252..49fe399 100644 --- a/backend/core/tests/Core.APITests/Core.APITests.csproj +++ b/backend/core/tests/Core.APITests/Core.APITests.csproj @@ -9,16 +9,16 @@ - - - - - - - - - - + + + + + + + + + + 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..a99439a --- /dev/null +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs @@ -0,0 +1,49 @@ +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.DependencyInjection; +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/SignatureVerificationMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs index 8201c09..5d7dc70 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs @@ -15,146 +15,145 @@ using System.Net.Http.Headers; using System.Text; -namespace Core.APITests.ResponseHandlingTests.SignatureVerificationMiddlewareTests +namespace Core.APITests.ResponseHandlingTests.SignatureVerificationMiddlewareTests; + +[TestClass] +public class SignatureVerificationMiddlewareTests { - [TestClass] - public class SignatureVerificationMiddlewareTests + [TestMethod] + public void VerifySignature_ValidSignature_ReturnsTrue() { - [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 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_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}"); - - var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); - - var validSignature = SignatureAlgorithm.Ed25519.Sign(key, payload); - - // Modifying the payload to create an invalid signature - byte[] modifiedPayload = Encoding.UTF8.GetBytes("{\"timestamp\": 17001363844}"); - - // Act - bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, modifiedPayload, validSignature); - - // Assert - Assert.IsFalse(result); - } - - public record MessageObject(string Message); - - [TestMethod] - public async Task VerifySignatureMiddleware_Successful_HttpProcessing() - { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddSingleton( - new StaticDateTimeProvider( - DateTimeOffset.FromUnixTimeSeconds(1577836800))); - }) - .Configure(app => - { - app.UseMiddleware(); + 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}"); - app.Run(async context => - { - var msg = await context.Request.ReadFromJsonAsync(); - await context.Response.WriteAsync($"Hello World {msg.Message}!"); - }); - }); - }) - .StartAsync(); - - var client = host.GetTestClient(); - - // 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-timestamp", "1577836800"); - 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.OK, response.StatusCode); - - var responseString = await response.Content.ReadAsStringAsync(); - Assert.AreEqual("Hello World HELLO!", responseString); - } - - [TestMethod] - public async Task VerifySignatureMiddleware_Failed() - { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddSingleton( - new StaticDateTimeProvider( - DateTimeOffset.FromUnixTimeSeconds(1577836800))); - }) - .Configure(app => + 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_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}"); + + var key = Key.Import(SignatureAlgorithm.Ed25519, privateKey, KeyBlobFormat.RawPrivateKey); + + var validSignature = SignatureAlgorithm.Ed25519.Sign(key, payload); + + // Modifying the payload to create an invalid signature + byte[] modifiedPayload = Encoding.UTF8.GetBytes("{\"timestamp\": 17001363844}"); + + // Act + bool result = SignatureVerificationMiddleware.VerifySignature(publicKey, modifiedPayload, validSignature); + + // Assert + Assert.IsFalse(result); + } + + public record MessageObject(string Message); + + [TestMethod] + public async Task VerifySignatureMiddleware_Successful_HttpProcessing() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton( + new StaticDateTimeProvider( + DateTimeOffset.FromUnixTimeSeconds(1577836800))); + }) + .Configure(app => + { + app.UseMiddleware(); + + app.Run(async context => { - app.UseMiddleware(); + var msg = await context.Request.ReadFromJsonAsync(); + await context.Response.WriteAsync($"Hello World {msg.Message}!"); + }); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); - app.Run(async context => - { - var msg = await context.Request.ReadFromJsonAsync(); - await context.Response.WriteAsync($"Hello World {msg.Message}!"); - }); + // 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-timestamp", "1577836800"); + 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.OK, response.StatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("Hello World HELLO!", responseString); + } + + [TestMethod] + public async Task VerifySignatureMiddleware_Failed() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton( + new StaticDateTimeProvider( + DateTimeOffset.FromUnixTimeSeconds(1577836800))); + }) + .Configure(app => + { + app.UseMiddleware(); + + app.Run(async context => + { + var msg = await context.Request.ReadFromJsonAsync(); + await context.Response.WriteAsync($"Hello World {msg.Message}!"); }); - }) - .StartAsync(); + }); + }) + .StartAsync(); - var client = host.GetTestClient(); + var client = host.GetTestClient(); - // 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-timestamp", "1577836801"); - request.Headers.Add("x-signature", "ksnz8fzvQerq3uTgYYisKqLu/tZJWcYQYPW4UAl62FREqm6T9PDGiAIjwiePL6SC4jE7X59r8llhUQqgQKQ1DQ=="); - request.Headers.Add("x-public-key", "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="); + // 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-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); + // send request + var response = await client.SendAsync(request); - Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); - var responseString = await response.Content.ReadAsStringAsync(); - Assert.AreEqual("{\"Errors\":[{\"Code\":\"Forbidden\",\"Message\":\"Invalid signature\",\"Target\":\"x-signature\"}]}", responseString); - } + var responseString = await response.Content.ReadAsStringAsync(); + Assert.AreEqual("{\"Errors\":[{\"Code\":\"Forbidden\",\"Message\":\"Invalid signature\",\"Target\":\"x-signature\"}]}", responseString); } } \ No newline at end of file From 5ee93a28071bfd4dd9e37399150c853945145465 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 16 Feb 2024 13:00:52 +0000 Subject: [PATCH 05/12] Prepend comments to source files --- .../ResponseHandlingTests/ExceptionMiddlewareTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs index a99439a..fcbfcce 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs @@ -1,3 +1,7 @@ +// 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; From 92c4cf447582333b7af63c600eb90af76a6171eb Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Fri, 16 Feb 2024 14:11:42 +0100 Subject: [PATCH 06/12] Added SignatureAlgorithmHeader enum and verification Added a new enum `SignatureAlgorithmHeader` to the `SignatureVerificationMiddleware` class in the `Core.API.ResponseHandling` namespace. This enum currently includes `ED25519` as a value. A check has been implemented in the same class to verify if the `algorithmHeader` can be parsed into the `SignatureAlgorithmHeader` enum. If parsing fails, an error is logged and a custom error response with a `Forbidden` status code is returned. Additionally, a new header `x-algorithm` has been added to the config headers in the `requestInterceptor` function in `axios.ts`, indicating the algorithm used for the signature. --- .../SignatureVerificationMiddleware.cs | 13 +++++++++++++ mobile/src/utils/axios.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index d558e1f..ad25ec5 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -11,6 +11,11 @@ namespace Core.API.ResponseHandling { + public enum SignatureAlgorithmHeader + { + ED25519 + } + public class SignatureVerificationMiddleware { private readonly RequestDelegate _next; @@ -61,6 +66,14 @@ public async Task Invoke(HttpContext context) return; } + if(!Enum.TryParse(algorithmHeader, ignoreCase: true, out var algorithm)) + { + _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; + } + // Check if the timestamp is within the allowed time if (!IsWithinAllowedTime(timestampHeaderLong)) { diff --git a/mobile/src/utils/axios.ts b/mobile/src/utils/axios.ts index d1ebbd5..01b6a69 100644 --- a/mobile/src/utils/axios.ts +++ b/mobile/src/utils/axios.ts @@ -63,6 +63,7 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { config.headers["x-public-key"] = pubKeyFromStore; config.headers["x-timestamp"] = sigData.timestamp; config.headers["x-signature"] = sigData.signature; + config.headers["x-algorithm"] = "ED25519"; } } } From b59d1563d8716bef51b84b4ccd2d6eb2e2625034 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Fri, 16 Feb 2024 14:25:59 +0100 Subject: [PATCH 07/12] Refactor `SignatureVerificationMiddleware` and enhance tests Refactored the `SignatureVerificationMiddleware` class by moving the `SignatureAlgorithmHeader` enum inside the class, adjusting the order of checks in the `Invoke` method, and improving error messages. Added summary and TODO comments for clarity. Enhanced `SignatureVerificationMiddlewareTests` by implementing `IDisposable`, adding a `host` and `client` field, and introducing a `TestInitialize` method named `Init`. Test method names were made more descriptive and a new test method `VerifySignatureMiddleware_InvalidAlgorithm` was added. The `Dispose` method was implemented to clean up after each test. --- .../SignatureVerificationMiddleware.cs | 39 +++--- .../SignatureVerificationMiddlewareTests.cs | 119 ++++++++++-------- 2 files changed, 89 insertions(+), 69 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index ad25ec5..a2be09a 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -11,11 +11,15 @@ namespace Core.API.ResponseHandling { - public enum SignatureAlgorithmHeader - { - ED25519 - } - + /// + /// 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; @@ -32,6 +36,11 @@ public SignatureVerificationMiddleware( _logger = logger; } + public enum SignatureAlgorithmHeader + { + ED25519 + } + public async Task Invoke(HttpContext context) { // Retrieve headers from the request @@ -41,6 +50,14 @@ public async Task Invoke(HttpContext context) string? timestampHeader = context.Request.Headers["x-timestamp"]; // Make sure the headers are present + if(!Enum.TryParse(algorithmHeader, ignoreCase: true, out var algorithm)) + { + _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; + } + if (string.IsNullOrWhiteSpace(signatureHeader)) { _logger.LogError("Missing signature header"); @@ -66,23 +83,17 @@ public async Task Invoke(HttpContext context) return; } - if(!Enum.TryParse(algorithmHeader, ignoreCase: true, out var algorithm)) - { - _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; - } - // 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", "timestamp")); + 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 diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs index 5d7dc70..ac7442f 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/SignatureVerificationMiddlewareTests.cs @@ -18,10 +18,43 @@ namespace Core.APITests.ResponseHandlingTests.SignatureVerificationMiddlewareTests; [TestClass] -public class SignatureVerificationMiddlewareTests +public sealed class SignatureVerificationMiddlewareTests : IDisposable { + 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_ReturnsTrue() + public void VerifySignature_ValidSignature() { var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; var publicKey = Convert.FromBase64String(publicKeyB64); @@ -41,7 +74,7 @@ public void VerifySignature_ValidSignature_ReturnsTrue() } [TestMethod] - public void VerifySignature_InvalidSignature_ReturnsFalse() + public void VerifySignature_InvalidSignature() { var publicKeyB64 = "gwZ+LyQ+VLaIsWeSq3QFh+WaZHNgl07pXul++BsezoY="; var publicKey = Convert.FromBase64String(publicKeyB64); @@ -66,38 +99,13 @@ public void VerifySignature_InvalidSignature_ReturnsFalse() public record MessageObject(string Message); [TestMethod] - public async Task VerifySignatureMiddleware_Successful_HttpProcessing() + public async Task VerifySignatureMiddleware_ValidSignature() { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddSingleton( - new StaticDateTimeProvider( - DateTimeOffset.FromUnixTimeSeconds(1577836800))); - }) - .Configure(app => - { - app.UseMiddleware(); - - app.Run(async context => - { - var msg = await context.Request.ReadFromJsonAsync(); - await context.Response.WriteAsync($"Hello World {msg.Message}!"); - }); - }); - }) - .StartAsync(); - - var client = host.GetTestClient(); - // 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="); @@ -112,38 +120,34 @@ public async Task VerifySignatureMiddleware_Successful_HttpProcessing() } [TestMethod] - public async Task VerifySignatureMiddleware_Failed() + public async Task VerifySignatureMiddleware_InvalidSignature() { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddSingleton( - new StaticDateTimeProvider( - DateTimeOffset.FromUnixTimeSeconds(1577836800))); - }) - .Configure(app => - { - app.UseMiddleware(); + // 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="); - app.Run(async context => - { - var msg = await context.Request.ReadFromJsonAsync(); - await context.Response.WriteAsync($"Hello World {msg.Message}!"); - }); - }); - }) - .StartAsync(); + // send request + var response = await client.SendAsync(request); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); - var client = host.GetTestClient(); + 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="); @@ -154,6 +158,11 @@ public async Task VerifySignatureMiddleware_Failed() Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); var responseString = await response.Content.ReadAsStringAsync(); - Assert.AreEqual("{\"Errors\":[{\"Code\":\"Forbidden\",\"Message\":\"Invalid signature\",\"Target\":\"x-signature\"}]}", responseString); + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Invalid Header","Target":"x-algorithm"}]}""", responseString); + } + + public void Dispose() + { + host.Dispose(); } } \ No newline at end of file From 14a366fd21c52399a0fec9618e991df9eb7bd3d6 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Fri, 16 Feb 2024 16:37:35 +0100 Subject: [PATCH 08/12] Add middleware to verify pubkey is linked to customer Add various tests --- backend/core/src/Core.API/Program.cs | 2 + .../PublicKeyLinkedMiddleware.cs | 82 ++++++++++++ .../FakeCustomerDeviceRepository.cs | 51 ++++++++ .../ExceptionMiddlewareTests.cs | 8 +- .../PublicKeyLinkedMiddlewareTests.cs | 118 ++++++++++++++++++ .../tests/Core.APITests/TestAuthHandler.cs | 31 +++++ 6 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs create mode 100644 backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs create mode 100644 backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs create mode 100644 backend/core/tests/Core.APITests/TestAuthHandler.cs 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..d327f07 --- /dev/null +++ b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs @@ -0,0 +1,82 @@ +using Core.Domain.Repositories; +using System.Net; +using System.Security.Claims; +using System.Security.Principal; + +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; + + public PublicKeyLinkedMiddleware( + ICustomerDeviceRepository customerDeviceRepository, + RequestDelegate next) + { + _customerDeviceRepository = customerDeviceRepository; + _next = next; + } + + 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)) + { + // TODO: make this a custom error + + context.Response.StatusCode = (int)HttpStatusCode.Forbidden; + await context.Response.WriteAsync("Public key not linked to user"); + 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); + } +} \ No newline at end of file diff --git a/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs new file mode 100644 index 0000000..7179ac0 --- /dev/null +++ b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs @@ -0,0 +1,51 @@ +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(); + } +} \ No newline at end of file diff --git a/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs index fcbfcce..3223e9a 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/ExceptionMiddlewareTests.cs @@ -2,13 +2,12 @@ // 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.API.ResponseHandling; using Core.Domain; using Core.Domain.Exceptions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.Net; @@ -33,10 +32,7 @@ public async Task ExistingKey_Returns_Conflict() { app.ConfigureCustomExceptionMiddleware(); - app.Run(context => - { - throw new CustomErrorsException(DomainErrorCode.ExistingKeyError.ToString(), "TEST", "Verification needed."); - }); + app.Run(context => throw new CustomErrorsException(DomainErrorCode.ExistingKeyError.ToString(), "TEST", "Verification needed.")); }); }) .StartAsync(); 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..c13f11f --- /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("Public key not linked to user", + 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("Public key not linked to user", + 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/TestAuthHandler.cs b/backend/core/tests/Core.APITests/TestAuthHandler.cs new file mode 100644 index 0000000..d70e467 --- /dev/null +++ b/backend/core/tests/Core.APITests/TestAuthHandler.cs @@ -0,0 +1,31 @@ +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); + } +} \ No newline at end of file From d99e63f5de25f759f83645b01ed0ab47559e845c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 16 Feb 2024 15:37:56 +0000 Subject: [PATCH 09/12] Prepend comments to source files --- .../Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs | 6 +++++- .../tests/Core.APITests/FakeCustomerDeviceRepository.cs | 6 +++++- backend/core/tests/Core.APITests/TestAuthHandler.cs | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs index d327f07..70bdce4 100644 --- a/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs @@ -1,3 +1,7 @@ +// 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.Repositories; using System.Net; using System.Security.Claims; @@ -79,4 +83,4 @@ private static bool SkipEndpoint(HttpContext context) return context.Request.Path.StartsWithSegments("/health") || excludeList.Contains(endpointName); } -} \ No newline at end of file +} diff --git a/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs index 7179ac0..7f8255b 100644 --- a/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs +++ b/backend/core/tests/Core.APITests/FakeCustomerDeviceRepository.cs @@ -1,3 +1,7 @@ +// 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; @@ -48,4 +52,4 @@ public void Update(CustomerOTPKeyStore entity) { throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/backend/core/tests/Core.APITests/TestAuthHandler.cs b/backend/core/tests/Core.APITests/TestAuthHandler.cs index d70e467..ac1a640 100644 --- a/backend/core/tests/Core.APITests/TestAuthHandler.cs +++ b/backend/core/tests/Core.APITests/TestAuthHandler.cs @@ -1,3 +1,7 @@ +// 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; @@ -28,4 +32,4 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(result); } -} \ No newline at end of file +} From a3398ac58e297224919a57321d82eed94a13e252 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Mon, 19 Feb 2024 10:33:26 +0100 Subject: [PATCH 10/12] Improve linked error response --- .../PublicKeyLinkedMiddleware.cs | 26 ++++++++++++++----- .../PublicKeyLinkedMiddlewareTests.cs | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs index 70bdce4..44ac067 100644 --- a/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/PublicKeyLinkedMiddleware.cs @@ -2,10 +2,12 @@ // 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.Repositories; 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; @@ -17,13 +19,16 @@ public class PublicKeyLinkedMiddleware { private readonly ICustomerDeviceRepository _customerDeviceRepository; private readonly RequestDelegate _next; + private readonly ILogger _logger; public PublicKeyLinkedMiddleware( ICustomerDeviceRepository customerDeviceRepository, - RequestDelegate next) + RequestDelegate next, + ILogger logger) { _customerDeviceRepository = customerDeviceRepository; _next = next; + _logger = logger; } public async Task Invoke(HttpContext context) @@ -41,10 +46,9 @@ public async Task Invoke(HttpContext context) if (customerDevices is null || customerDevices.PublicKeys.All(keys => keys.PublicKey != pubKey)) { - // TODO: make this a custom error - - context.Response.StatusCode = (int)HttpStatusCode.Forbidden; - await context.Response.WriteAsync("Public key not linked to user"); + _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; } } @@ -83,4 +87,14 @@ private static bool SkipEndpoint(HttpContext context) 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/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs b/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs index c13f11f..c6f6312 100644 --- a/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs +++ b/backend/core/tests/Core.APITests/ResponseHandlingTests/PublicKeyLinkedMiddlewareTests.cs @@ -87,7 +87,7 @@ public async Task Verify_Returns_Forbidden() var response = await client.GetAsync("/verify"); var responseString = await response.Content.ReadAsStringAsync(); - Assert.AreEqual("Public key not linked to user", + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Unknown public-key","Target":"x-public-key"}]}""", responseString); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); } @@ -100,7 +100,7 @@ public async Task Verify_Returns_Ok() var response = await client.GetAsync("/verify"); var responseString = await response.Content.ReadAsStringAsync(); - Assert.AreEqual("Public key not linked to user", + Assert.AreEqual("""{"Errors":[{"Code":"Forbidden","Message":"Unknown public-key","Target":"x-public-key"}]}""", responseString); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); } From 3987cbcc4055037e3755444b7ff1da841577bd86 Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Mon, 19 Feb 2024 10:47:59 +0100 Subject: [PATCH 11/12] Comment fix --- .../ResponseHandling/SignatureVerificationMiddleware.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs index a2be09a..f6626f3 100644 --- a/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs +++ b/backend/core/src/Core.API/ResponseHandling/SignatureVerificationMiddleware.cs @@ -36,7 +36,7 @@ public SignatureVerificationMiddleware( _logger = logger; } - public enum SignatureAlgorithmHeader + private enum SignatureAlgorithmHeader { ED25519 } @@ -50,7 +50,7 @@ public async Task Invoke(HttpContext context) string? timestampHeader = context.Request.Headers["x-timestamp"]; // Make sure the headers are present - if(!Enum.TryParse(algorithmHeader, ignoreCase: true, out var algorithm)) + if (!Enum.TryParse(algorithmHeader, ignoreCase: true, out _)) { _logger.LogError("Invalid algorithm header"); var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid Header", "x-algorithm")); @@ -143,7 +143,8 @@ private bool IsWithinAllowedTime(long timestampHeaderLong) 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"; @@ -175,4 +176,4 @@ public static void ConfigureSignatureVerificationMiddleware(this IApplicationBui app.UseMiddleware(); } } -} +} \ No newline at end of file From f8582625f96bc8d40b6e42fafa5a2cd5c8f255ad Mon Sep 17 00:00:00 2001 From: Raymen Scholten Date: Mon, 19 Feb 2024 12:56:14 +0100 Subject: [PATCH 12/12] Fix tests --- .../utils/__tests__/authentication.test.ts | 30 +++++++++++++------ mobile/src/utils/axios.ts | 8 +++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/mobile/src/utils/__tests__/authentication.test.ts b/mobile/src/utils/__tests__/authentication.test.ts index 95a86e1..fa05834 100644 --- a/mobile/src/utils/__tests__/authentication.test.ts +++ b/mobile/src/utils/__tests__/authentication.test.ts @@ -8,12 +8,10 @@ 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)); - -jest.useFakeTimers().setSystemTime(new Date("2020-01-01T00:00:00Z")); +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 = @@ -22,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"); @@ -45,15 +50,22 @@ 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" }; - const sigData = getSignatureHeaders(data, privKey); + + // 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"); - }, 10000); + }); }); diff --git a/mobile/src/utils/axios.ts b/mobile/src/utils/axios.ts index 01b6a69..eefd807 100644 --- a/mobile/src/utils/axios.ts +++ b/mobile/src/utils/axios.ts @@ -59,7 +59,7 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { config.headers["Authorization"] = `Bearer ${accessToken}`; if (pubKeyFromStore !== null && privKeyFromStore != null) { - const sigData = getSignatureHeaders(config.data, privKeyFromStore); + 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; @@ -71,9 +71,11 @@ async function requestInterceptor(config: InternalAxiosRequestConfig) { return config; } +// 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(data: any, privKeyFromStore: string) { - const timestampInSeconds = Math.floor(Date.now() / 1000).toString(); // Convert current time to Unix timestamp in seconds +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;