using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; namespace StellaOps.Canonical.Json; /// /// Canonical JSON serialization with deterministic hashing. /// Produces bit-identical output across environments for proof replay. /// /// /// Key guarantees: /// /// Object keys are sorted alphabetically (Ordinal comparison) /// No whitespace or formatting variations /// Consistent number formatting /// UTF-8 encoding without BOM /// /// public static class CanonJson { private static readonly JsonSerializerOptions DefaultSerializerOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static readonly JsonWriterOptions DefaultWriterOptions = new() { Indented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; /// /// Serializes an object to a canonical JSON string. /// Object keys are recursively sorted using Ordinal comparison. /// /// The type to serialize. /// The object to serialize. /// Canonical JSON string. public static string Serialize(T obj) { var bytes = Canonicalize(obj); return Encoding.UTF8.GetString(bytes); } /// /// Serializes an object to a canonical JSON string using custom serializer options. /// Object keys are recursively sorted using Ordinal comparison. /// /// The type to serialize. /// The object to serialize. /// JSON serializer options to use for initial serialization. /// Canonical JSON string. public static string Serialize(T obj, JsonSerializerOptions options) { var bytes = Canonicalize(obj, options); return Encoding.UTF8.GetString(bytes); } /// /// Canonicalizes an object to a deterministic byte array. /// Object keys are recursively sorted using Ordinal comparison. /// /// The type to serialize. /// The object to canonicalize. /// UTF-8 encoded canonical JSON bytes. public static byte[] Canonicalize(T obj) { var json = JsonSerializer.SerializeToUtf8Bytes(obj, DefaultSerializerOptions); using var doc = JsonDocument.Parse(json); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions); WriteElementSorted(doc.RootElement, writer); writer.Flush(); return ms.ToArray(); } /// /// Canonicalizes an object using custom serializer options. /// Object keys are recursively sorted using Ordinal comparison. /// /// The type to serialize. /// The object to canonicalize. /// JSON serializer options to use for initial serialization. /// UTF-8 encoded canonical JSON bytes. public static byte[] Canonicalize(T obj, JsonSerializerOptions options) { var json = JsonSerializer.SerializeToUtf8Bytes(obj, options); using var doc = JsonDocument.Parse(json); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, CreateWriterOptions(options)); WriteElementSorted(doc.RootElement, writer); writer.Flush(); return ms.ToArray(); } /// /// Canonicalizes raw JSON bytes by parsing and re-sorting keys. /// Use this when you have existing JSON that needs to be canonicalized. /// /// UTF-8 encoded JSON bytes. /// UTF-8 encoded canonical JSON bytes. public static byte[] CanonicalizeParsedJson(ReadOnlySpan jsonBytes) { var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default); using var doc = JsonDocument.ParseValue(ref reader); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions); WriteElementSorted(doc.RootElement, writer); writer.Flush(); return ms.ToArray(); } /// /// Canonicalizes raw JSON bytes using a custom encoder for output. /// /// UTF-8 encoded JSON bytes. /// Encoder to use for output escaping. /// UTF-8 encoded canonical JSON bytes. public static byte[] CanonicalizeParsedJson(ReadOnlySpan jsonBytes, JavaScriptEncoder encoder) { ArgumentNullException.ThrowIfNull(encoder); var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default); using var doc = JsonDocument.ParseValue(ref reader); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false, Encoder = encoder }); WriteElementSorted(doc.RootElement, writer); writer.Flush(); return ms.ToArray(); } private static JsonWriterOptions CreateWriterOptions(JsonSerializerOptions? options) { var encoder = options?.Encoder ?? DefaultWriterOptions.Encoder; return new JsonWriterOptions { Indented = false, Encoder = encoder }; } private static void WriteElementSorted(JsonElement el, Utf8JsonWriter w) { switch (el.ValueKind) { case JsonValueKind.Object: w.WriteStartObject(); foreach (var prop in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) { w.WritePropertyName(prop.Name); WriteElementSorted(prop.Value, w); } w.WriteEndObject(); break; case JsonValueKind.Array: w.WriteStartArray(); foreach (var item in el.EnumerateArray()) { WriteElementSorted(item, w); } w.WriteEndArray(); break; default: el.WriteTo(w); break; } } /// /// Computes SHA-256 hash of bytes, returns lowercase hex string. /// /// The bytes to hash. /// 64-character lowercase hex string. public static string Sha256Hex(ReadOnlySpan bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); /// /// Computes SHA-256 hash of bytes, returns prefixed hash string. /// /// The bytes to hash. /// Hash string with "sha256:" prefix. public static string Sha256Prefixed(ReadOnlySpan bytes) => "sha256:" + Sha256Hex(bytes); /// /// Canonicalizes an object and computes its SHA-256 hash. /// /// The type to serialize. /// The object to hash. /// 64-character lowercase hex string. public static string Hash(T obj) { var canonical = Canonicalize(obj); return Sha256Hex(canonical); } /// /// Canonicalizes an object and computes its prefixed SHA-256 hash. /// /// The type to serialize. /// The object to hash. /// Hash string with "sha256:" prefix. public static string HashPrefixed(T obj) { var canonical = Canonicalize(obj); return Sha256Prefixed(canonical); } /// /// Canonicalizes an object with version marker for content-addressed hashing. /// The version marker is embedded as the first field in the canonical JSON, /// ensuring stable hashes even if canonicalization logic evolves. /// /// The type to serialize. /// The object to canonicalize. /// Canonicalization version (default: Current). /// UTF-8 encoded canonical JSON bytes with version marker. public static byte[] CanonicalizeVersioned(T obj, string version = CanonVersion.Current) { ArgumentException.ThrowIfNullOrWhiteSpace(version); var json = JsonSerializer.SerializeToUtf8Bytes(obj, DefaultSerializerOptions); using var doc = JsonDocument.Parse(json); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, DefaultWriterOptions); WriteElementVersioned(doc.RootElement, writer, version); writer.Flush(); return ms.ToArray(); } /// /// Canonicalizes an object with version marker using custom serializer options. /// /// The type to serialize. /// The object to canonicalize. /// JSON serializer options to use for initial serialization. /// Canonicalization version (default: Current). /// UTF-8 encoded canonical JSON bytes with version marker. public static byte[] CanonicalizeVersioned(T obj, JsonSerializerOptions options, string version = CanonVersion.Current) { ArgumentException.ThrowIfNullOrWhiteSpace(version); var json = JsonSerializer.SerializeToUtf8Bytes(obj, options); using var doc = JsonDocument.Parse(json); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms, CreateWriterOptions(options)); WriteElementVersioned(doc.RootElement, writer, version); writer.Flush(); return ms.ToArray(); } private static void WriteElementVersioned(JsonElement el, Utf8JsonWriter w, string version) { if (el.ValueKind == JsonValueKind.Object) { w.WriteStartObject(); // Write version marker first (underscore prefix ensures lexicographic first position) w.WriteString(CanonVersion.VersionFieldName, version); // Write remaining properties sorted foreach (var prop in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) { if (string.Equals(prop.Name, CanonVersion.VersionFieldName, StringComparison.Ordinal)) { continue; } w.WritePropertyName(prop.Name); WriteElementSorted(prop.Value, w); } w.WriteEndObject(); } else { // Non-object root: wrap in object with version marker w.WriteStartObject(); w.WriteString(CanonVersion.VersionFieldName, version); w.WritePropertyName("_value"); WriteElementSorted(el, w); w.WriteEndObject(); } } /// /// Computes SHA-256 hash of versioned canonical representation. /// /// The type to serialize. /// The object to hash. /// Canonicalization version (default: Current). /// 64-character lowercase hex string. public static string HashVersioned(T obj, string version = CanonVersion.Current) { var canonical = CanonicalizeVersioned(obj, version); return Sha256Hex(canonical); } /// /// Computes prefixed SHA-256 hash of versioned canonical representation. /// /// The type to serialize. /// The object to hash. /// Canonicalization version (default: Current). /// Hash string with "sha256:" prefix. public static string HashVersionedPrefixed(T obj, string version = CanonVersion.Current) { var canonical = CanonicalizeVersioned(obj, version); return Sha256Prefixed(canonical); } }