diff --git a/src/Particular.LicensingComponent.Report.UnitTests/ApprovalFiles/ApiApprovals.Approve.approved.txt b/src/Particular.LicensingComponent.Report.UnitTests/ApprovalFiles/ApiApprovals.Approve.approved.txt index 88ec48e..8043d99 100644 --- a/src/Particular.LicensingComponent.Report.UnitTests/ApprovalFiles/ApiApprovals.Approve.approved.txt +++ b/src/Particular.LicensingComponent.Report.UnitTests/ApprovalFiles/ApiApprovals.Approve.approved.txt @@ -58,9 +58,10 @@ namespace Particular.LicensingComponent.Report } public class ReportValidationResult { - public ReportValidationResult(Particular.LicensingComponent.Report.SignedReport signedReport) { } + public ReportValidationResult() { } + public string? InvalidReason { get; } public bool IsValid { get; } - public string ReportId { get; } + public string? ReportId { get; } } public static class SerializationOptions { @@ -77,6 +78,10 @@ namespace Particular.LicensingComponent.Report public Particular.LicensingComponent.Report.Report ReportData { get; init; } public string Signature { get; init; } } + public static class ValidatingReportReader + { + public static Particular.LicensingComponent.Report.Report? ReadAndValidate(System.IO.Stream stream, out Particular.LicensingComponent.Report.ReportValidationResult validationResult) { } + } } namespace Particular.LicensingComponent.Report.Utility { diff --git a/src/Particular.LicensingComponent.Report.UnitTests/LicensingComponent_Report_Signature_Tests.cs b/src/Particular.LicensingComponent.Report.UnitTests/LicensingComponent_Report_Signature_Tests.cs index 495b699..aae8ec0 100644 --- a/src/Particular.LicensingComponent.Report.UnitTests/LicensingComponent_Report_Signature_Tests.cs +++ b/src/Particular.LicensingComponent.Report.UnitTests/LicensingComponent_Report_Signature_Tests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using Microsoft.VisualStudio.TestPlatform.ObjectModel; using NUnit.Framework; @@ -34,10 +35,10 @@ public void Should_deserialize_report_with_signature() //Act var reportString = JsonSerializer.Serialize(report, SerializationOptions.NotIndentedWithNoEscaping); - var deserialized = JsonSerializer.Deserialize(reportString, SerializationOptions.NotIndentedWithNoEscaping); + var _ = Parse(reportString, out var validationResult); //Assert - Assert.That(ValidateReport(deserialized)); + Assert.That(Validate(validationResult)); } [Test] @@ -49,26 +50,25 @@ public void Should_not_allow_tempering_with_report() //Act reportString = reportString.Replace("\"Throughput\": 42", "\"Throughput\": 13"); - var deserialized = JsonSerializer.Deserialize(reportString, SerializationOptions.NotIndentedWithNoEscaping); + var _ = Parse(reportString, out var validationResult); //Assert - Assert.That(ValidateReport(deserialized), Is.False); + Assert.That(Validate(validationResult), Is.False); } [Test] public void Should_be_able_to_read_a_V1_report() { //Arrange - var reportString = GetResource("throughput-report-v1.0.json"); + using var stream = GetResourceStream("throughput-report-v1.0.json"); //Act - var report = JsonSerializer.Deserialize(reportString, SerializationOptions.NotIndentedWithNoEscaping); - var data = report!.ReportData; + var data = ValidatingReportReader.ReadAndValidate(stream!, out var validationResult); //Assert // Want to be explicit with asserts to ensure that a 1.0 report can be read correctly // An approval test would be too easy to just accept changes on - Assert.That(data.CustomerName, Is.EqualTo("Testing")); + Assert.That(data!.CustomerName, Is.EqualTo("Testing")); Assert.That(data.MessageTransport, Is.EqualTo("RabbitMQ")); Assert.That(data.ReportMethod, Is.EqualTo("ThroughputTool: RabbitMQ Admin")); Assert.That(data.ToolVersion, Is.EqualTo("1.0.0")); @@ -84,25 +84,22 @@ public void Should_be_able_to_read_a_V1_report() Assert.That(data.TotalQueues, Is.EqualTo(7)); - Assert.That(report.Signature, Is.EqualTo("ybIzoo9ogZtbSm5+jJa3GxncjCX3fxAfiLSI7eogG20KjJiv43aCE+7Lsvhkat7AALM34HgwI3VsgzRmyLYXD5n0+XRrWXNgeRGbLEG6d1W2djLRHNjXo423zpGTYDeMq3vhI9yAcil0K0dCC/ZCnw8dPd51pNmgKYIvrfELW0hyN70trUeCMDhYRfXruWLNe8Hfy+tS8Bm13B5vknXNlAjBIuGjXn3XILRRSVrTbb4QMIRzSluSnSTFPTCyE9wMWwC0BUGSf7ZEA0XdeN6UkaO/5URSOQVesiSLRqQWbfUc87XlY1hMs5Z7kLSOr5WByIQIfQKum1nGVjLMzshyhQ==")); - - Assert.That(ValidateReport(report)); + Assert.That(Validate(validationResult)); } [Test] public void Should_be_able_to_read_a_V2_report() { //Arrange - var reportString = GetResource("throughput-report-v2.0.json"); + using var stream = GetResourceStream("throughput-report-v2.0.json"); //Act - var report = JsonSerializer.Deserialize(reportString, SerializationOptions.NotIndentedWithNoEscaping); - var data = report!.ReportData; + var data = ValidatingReportReader.ReadAndValidate(stream!, out var validationResult); //Assert // Want to be explicit with asserts to ensure that a 2.0 report can be read correctly // An approval test would be too easy to just accept changes on - Assert.That(data.CustomerName, Is.EqualTo("TestCustomer")); + Assert.That(data!.CustomerName, Is.EqualTo("TestCustomer")); Assert.That(data.MessageTransport, Is.EqualTo("AzureServiceBus")); Assert.That(data.ReportMethod, Is.EqualTo("Broker")); Assert.That(data.ToolType, Is.EqualTo("Platform Licensing Component")); @@ -129,9 +126,7 @@ public void Should_be_able_to_read_a_V2_report() Assert.That(data.EnvironmentInformation.EnvironmentData.ContainsKey("MonitoringEnabled"), Is.True); Assert.That(data.EnvironmentInformation.EnvironmentData["MonitoringEnabled"], Is.EqualTo("True")); - Assert.That(report.Signature, Is.EqualTo("IEbO4i0Jn54iHUzlwotHf9aw/fZIHY+dztY9cMRkWjVVo6AiYtihWR0mip793gRrWHOxHVobCpa4l5svRk16mBR+YAOrs3KNRVTzrl4+wL21e1u9zFuPNrHLtFeul+taJxV8ciA7zEgD7LMle9CcR/Vfm8BZ9mmD5W/DjsCYLCdVXfN4iRMlz+eW50mOHty21yJ0pOiYBooaN2EJexVY4Q+5FMyAkm0wucEPFyaQB6+SfcS37fEm807B7sXhtUPiW+einqDOX6uYF+MuXxUn1u9LxlEWKV9kPqXJnulxmoReHXHigP45pj/8m9jUzrQdagINl1uIOBkq5SMDccRfTA==")); - - Assert.That(ValidateReport(report)); + Assert.That(Validate(validationResult)); } #if !DEBUG @@ -176,22 +171,40 @@ public void ValidateReport(string reportFile) //Arrange var reportString = File.ReadAllText(reportFile); - var report = JsonSerializer.Deserialize(reportString, SerializationOptions.NotIndentedWithNoEscaping); + var _ = Parse(reportString, out var validationResult); //Assert - Assert.That(ValidateReport(report)); + Assert.That(validationResult.IsValid, Is.True); } + static bool Validate(ReportValidationResult validationResult) + { + if (validationResult.IsValid) + { + return true; + } + +#if DEBUG + if (validationResult.InvalidReason == "No private key available to validate signature") + { + Assert.Ignore("Ignoring report validation as this is a DEBUG build and the THROUGHPUT_REPORT_PRIVATEKEY_PEM environment variable is missing."); + return true; + } +#endif + return false; + } + + static Report? Parse(string rawJson, out ReportValidationResult validationResult) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(rawJson)); + return ValidatingReportReader.ReadAndValidate(stream, out validationResult); + } - string GetResource(string resourceName) + Stream? GetResourceStream(string resourceName) { var assembly = typeof(LicensingComponent_Report_Signature_Tests).Assembly; var assemblyName = assembly.GetName().Name; - using (var stream = assembly.GetManifestResourceStream($"{assemblyName}.{resourceName}")) - using (var reader = new StreamReader(stream!)) - { - return reader.ReadToEnd(); - } + return assembly.GetManifestResourceStream($"{assemblyName}.{resourceName}"); } SignedReport CreateReport() @@ -228,28 +241,4 @@ SignedReport CreateReport() Signature = Signature.SignReport(reportData) }; } - - bool ValidateReport(SignedReport? signedReport) - { - if (signedReport == null) - { - return false; - } - - try - { - var validationResult = new ReportValidationResult(signedReport); - return validationResult.IsValid; - } - catch (NoPrivateKeyException) - { -#if DEBUG - // We don't distribute the private key to do local testing, this only happens during CI - Assert.Ignore("Ignoring report validation as this is a DEBUG build and the THROUGHPUT_REPORT_PRIVATEKEY_PEM environment variable is missing."); - return true; -#endif - - throw; - } - } } \ No newline at end of file diff --git a/src/Particular.LicensingComponent.Report/ReportValidationResult.cs b/src/Particular.LicensingComponent.Report/ReportValidationResult.cs index a0aa373..321539d 100644 --- a/src/Particular.LicensingComponent.Report/ReportValidationResult.cs +++ b/src/Particular.LicensingComponent.Report/ReportValidationResult.cs @@ -1,8 +1,5 @@ namespace Particular.LicensingComponent.Report; -using System.Security.Cryptography; -using System.Text.Json; - /// /// Report validator /// @@ -14,52 +11,26 @@ public class ReportValidationResult public bool IsValid { get; internal set; } /// - /// Report id of the report being validated + /// Reason why the report is invalid, if applicable /// - public string ReportId { get; } - - internal ReportValidationResult(string reportId) - { - ReportId = reportId; - IsValid = true; - } + public string? InvalidReason { get; internal set; } /// - /// Method that tests whether the signed report is valid + /// Report id of the report being validated /// - /// - /// - public ReportValidationResult(SignedReport signedReport) - { - var reserializedReportBytes = JsonSerializer.SerializeToUtf8Bytes(signedReport.ReportData, SerializationOptions.NotIndentedWithNoEscaping); - - ReportId = Convert.ToHexString(SHA1.HashData(reserializedReportBytes)); + public string? ReportId { get; internal set; } - if (signedReport?.Signature is null) - { - return; - } - var pemData = Environment.GetEnvironmentVariable("THROUGHPUT_REPORT_PRIVATEKEY_PEM") ?? throw new NoPrivateKeyException(ReportId); - - byte[] signatureBytes; - try - { - signatureBytes = Convert.FromBase64String(signedReport.Signature); - } - catch (FormatException) - { - return; - } - - var correctSignature = Convert.ToBase64String(SHA512.HashData(reserializedReportBytes)); - - using var rsa = RSA.Create(); - - rsa.ImportFromPem(pemData); - var decryptedHash = rsa.Decrypt(signatureBytes, RSAEncryptionPadding.Pkcs1); - var decryptedSignature = Convert.ToBase64String(decryptedHash); + internal static ReportValidationResult Valid(string reportId) => new() + { + IsValid = true, + ReportId = reportId + }; - IsValid = correctSignature == decryptedSignature; - } + internal static ReportValidationResult Invalid(string? reportId, string reason) => new() + { + IsValid = false, + InvalidReason = reason, + ReportId = reportId + }; } diff --git a/src/Particular.LicensingComponent.Report/ValidatingReportReader.cs b/src/Particular.LicensingComponent.Report/ValidatingReportReader.cs new file mode 100644 index 0000000..0d4cfc4 --- /dev/null +++ b/src/Particular.LicensingComponent.Report/ValidatingReportReader.cs @@ -0,0 +1,100 @@ +namespace Particular.LicensingComponent.Report; + +using System.Buffers; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; + +/// +/// Helper class to read and validate a signed report +/// +public static class ValidatingReportReader +{ + /// + /// Reads and validates a signed report from a stream + /// + /// + /// + public static Report? ReadAndValidate(Stream stream, out ReportValidationResult validationResult) + { + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + if (!root.TryGetProperty(nameof(SignedReport.ReportData), out var reportDataElement)) + { + validationResult = ReportValidationResult.Invalid(null, "Could not find ReportData property"); + return null; + } + + validationResult = ValidateSignature(root, reportDataElement); + + return reportDataElement.Deserialize(); + } + + static ReportValidationResult ValidateSignature(JsonElement root, JsonElement reportDataElement) + { + // NOTE: In .NET 9 we can get the bytes directly from the JsonElement, but in .NET 8 we have to get the raw text and convert it to bytes ourselves. + var reportBytes = MinifyJsonElementToBytes(reportDataElement); + + var reportId = Convert.ToHexString(SHA1.HashData(reportBytes)); + + if (!root.TryGetProperty(nameof(SignedReport.Signature), out var signatureElement)) + { + return ReportValidationResult.Invalid(reportId, "Could not find signature"); + } + + var signature = signatureElement.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(signature)) + { + return ReportValidationResult.Invalid(reportId, "Signature is empty"); + } + + // Validate signature + var pemData = Environment.GetEnvironmentVariable("THROUGHPUT_REPORT_PRIVATEKEY_PEM"); + if (string.IsNullOrWhiteSpace(pemData)) + { + return ReportValidationResult.Invalid(reportId, "No private key available to validate signature"); + } + + byte[] signatureBytes; + try + { + signatureBytes = Convert.FromBase64String(signature); + } + catch (FormatException) + { + // NOTE: The signature is not valid base64, which means it is invalid. We will return false for IsValid in this case. + return ReportValidationResult.Invalid(reportId, "Signature could not be decoded"); + } + + var correctSignature = Convert.ToBase64String(SHA512.HashData(reportBytes)); + + using var rsa = RSA.Create(); + + rsa.ImportFromPem(pemData); + var decryptedHash = rsa.Decrypt(signatureBytes, RSAEncryptionPadding.Pkcs1); + var decryptedSignature = Convert.ToBase64String(decryptedHash); + + return (correctSignature == decryptedSignature) + ? ReportValidationResult.Valid(reportId) + : ReportValidationResult.Invalid(reportId, "Signature does not match report data"); + } + + static readonly JsonWriterOptions MinifyOptions = new() + { + Indented = false, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + static byte[] MinifyJsonElementToBytes(JsonElement element) + { + var bufferWriter = new ArrayBufferWriter(initialCapacity: 4096); + using var memoryStream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(bufferWriter, MinifyOptions)) + { + element.WriteTo(writer); + } + + return bufferWriter.WrittenMemory.ToArray(); + } +}