Canonicalize Eve invocation digests

This commit is contained in:
Meta Vens 2026-08-19 04:25:04 +02:00
commit 0d75021c8b
3 changed files with 101 additions and 0 deletions

View file

@ -0,0 +1,50 @@
using System;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
#nullable enable
namespace GameCult.Eve.Surface
{
/// <summary>Canonical digest for one immutable Eve command invocation.</summary>
public static class EveCommandInvocationHash
{
public static string Compute(EveSurfaceCommandRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
var canonical = new StringBuilder();
Append(request.Schema);
Append(request.ProviderId);
Append(request.SurfaceId);
Append(request.OperationRecord.OperationId);
Append(request.OperationRecord.SchemaId);
Append(request.OperationRecord.RouteKind);
Append(request.OperationRecord.RouteDescription);
Append(request.OperationRecord.IdempotencyKey);
foreach (var field in request.PayloadFields.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
Append(field.Key);
Append(field.Value);
}
Append(request.IssuedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
Append(request.ClientId);
Append(request.CommandBoundary);
Append(request.ReceiptSchema);
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString()));
var digest = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes) digest.Append(value.ToString("x2", CultureInfo.InvariantCulture));
return digest.ToString();
void Append(string? value)
{
value ??= "";
canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture));
canonical.Append(':');
canonical.Append(value);
}
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d5f81f817c44d1ab25d76166354364d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -68,6 +68,46 @@ public sealed class EveSurfaceSerializationCompatibilityTests
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
[Test]
public void CommandInvocationHashBindsEveryImmutableEnvelopeField()
{
var request = new EveSurfaceCommandRequest(
"provider",
"surface",
new CultMeshOperationInvocationDescriptor(
"launch",
"launch.v1",
new CultMeshRouteHint(CultMeshLocalityKind.Network, "commands"),
"command-1"),
CultMesh.OperationPayload(("shipId", "ship:one")),
DateTimeOffset.Parse("2026-08-19T00:00:00Z"),
"pilot:one",
"eve:commands",
EveCommandReceiptDocument.SchemaId);
var same = new EveSurfaceCommandRequest(
request.Schema,
request.ProviderId,
request.SurfaceId,
request.OperationRecord,
new Dictionary<string, string>(request.PayloadFields, StringComparer.Ordinal),
request.IssuedAt,
request.ClientId,
request.CommandBoundary,
request.ReceiptSchema);
var changedPayload = new EveSurfaceCommandRequest(
request.ProviderId,
request.SurfaceId,
request.Operation,
CultMesh.OperationPayload(("shipId", "ship:two")),
request.IssuedAt,
request.ClientId,
request.CommandBoundary,
request.ReceiptSchema);
Assert.That(EveCommandInvocationHash.Compute(same), Is.EqualTo(EveCommandInvocationHash.Compute(request)));
Assert.That(EveCommandInvocationHash.Compute(changedPayload), Is.Not.EqualTo(EveCommandInvocationHash.Compute(request)));
}
[Test]
public void LegacySevenFieldSurfaceDocumentDeserializes()
{