From 0d75021c8b5fe18aed7f75e261ee6690d38f79b9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 04:25:04 +0200 Subject: [PATCH] Canonicalize Eve invocation digests --- .../Runtime/EveCommandInvocationHash.cs | 50 +++++++++++++++++++ .../Runtime/EveCommandInvocationHash.cs.meta | 11 ++++ ...eSurfaceSerializationCompatibilityTests.cs | 40 +++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs new file mode 100644 index 0000000..507b163 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs @@ -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 +{ + /// Canonical digest for one immutable Eve command invocation. + 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); + } + } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta new file mode 100644 index 0000000..cf3cc3e --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d5f81f817c44d1ab25d76166354364d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 30156c3..52d71cc 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -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(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() {