Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,10 +35,10 @@ public void Should_deserialize_report_with_signature()

//Act
var reportString = JsonSerializer.Serialize(report, SerializationOptions.NotIndentedWithNoEscaping);
var deserialized = JsonSerializer.Deserialize<SignedReport>(reportString, SerializationOptions.NotIndentedWithNoEscaping);
var _ = Parse(reportString, out var validationResult);

//Assert
Assert.That(ValidateReport(deserialized));
Assert.That(Validate(validationResult));
}

[Test]
Expand All @@ -49,26 +50,25 @@ public void Should_not_allow_tempering_with_report()

//Act
reportString = reportString.Replace("\"Throughput\": 42", "\"Throughput\": 13");
var deserialized = JsonSerializer.Deserialize<SignedReport>(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<SignedReport>(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"));
Expand All @@ -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<SignedReport>(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"));
Expand All @@ -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
Expand Down Expand Up @@ -176,22 +171,40 @@ public void ValidateReport(string reportFile)

//Arrange
var reportString = File.ReadAllText(reportFile);
var report = JsonSerializer.Deserialize<SignedReport>(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()
Expand Down Expand Up @@ -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;
}
}
}
59 changes: 15 additions & 44 deletions src/Particular.LicensingComponent.Report/ReportValidationResult.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
namespace Particular.LicensingComponent.Report;

using System.Security.Cryptography;
using System.Text.Json;

/// <summary>
/// Report validator
/// </summary>
Expand All @@ -14,52 +11,26 @@ public class ReportValidationResult
public bool IsValid { get; internal set; }

/// <summary>
/// Report id of the report being validated
/// Reason why the report is invalid, if applicable
/// </summary>
public string ReportId { get; }

internal ReportValidationResult(string reportId)
{
ReportId = reportId;
IsValid = true;
}
public string? InvalidReason { get; internal set; }

/// <summary>
/// Method that tests whether the signed report is valid
/// Report id of the report being validated
/// </summary>
/// <param name="signedReport"></param>
/// <exception cref="NoPrivateKeyException"></exception>
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
};
}
100 changes: 100 additions & 0 deletions src/Particular.LicensingComponent.Report/ValidatingReportReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace Particular.LicensingComponent.Report;

using System.Buffers;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;

/// <summary>
/// Helper class to read and validate a signed report
/// </summary>
public static class ValidatingReportReader
{
/// <summary>
/// Reads and validates a signed report from a stream
/// </summary>
/// <param name="stream"></param>
/// <param name="validationResult"></param>
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<Report>();
}

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<byte>(initialCapacity: 4096);
using var memoryStream = new MemoryStream();
using (var writer = new Utf8JsonWriter(bufferWriter, MinifyOptions))
{
element.WriteTo(writer);
}

return bufferWriter.WrittenMemory.ToArray();
}
}