using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace PrivaPub.Federation.Signing { // RFC 8785, the JSON Canonicalization Scheme: members sorted by their names' UTF-16 code units, no whitespace, strings // escaped only where JSON must be, numbers as ECMAScript writes them. What an eddsa-jcs-2022 proof signs (FEP-8b32). public static class Jcs { public static string Serialize(JsonNode node) { var builder = new StringBuilder(); Write(builder, node); return builder.ToString(); } static void Write(StringBuilder builder, JsonNode node) { switch (node) { case null: builder.Append("null"); break; case JsonObject obj: builder.Append('{'); var first = true; foreach (var (name, value) in obj.OrderBy(p => p.Key, StringComparer.Ordinal)) { if (!first) builder.Append(','); first = false; String(builder, name); builder.Append(':'); Write(builder, value); } builder.Append('}'); break; case JsonArray array: builder.Append('['); for (var i = 0; i < array.Count; i++) { if (i > 0) builder.Append(','); Write(builder, array[i]); } builder.Append(']'); break; case JsonValue value: switch (value.GetValueKind()) { case JsonValueKind.String: String(builder, value.GetValue()); break; case JsonValueKind.Number: builder.Append(Number(double.Parse(value.ToJsonString(), NumberStyles.Float, CultureInfo.InvariantCulture))); break; case JsonValueKind.True: builder.Append("true"); break; case JsonValueKind.False: builder.Append("false"); break; default: builder.Append("null"); break; } break; } } static void String(StringBuilder builder, string text) { builder.Append('"'); foreach (var c in text) { switch (c) { case '"': builder.Append("\\\""); break; case '\\': builder.Append("\\\\"); break; case '\b': builder.Append("\\b"); break; case '\f': builder.Append("\\f"); break; case '\n': builder.Append("\\n"); break; case '\r': builder.Append("\\r"); break; case '\t': builder.Append("\\t"); break; default: if (c < 0x20) builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); else builder.Append(c); break; } } builder.Append('"'); } // ECMAScript's Number.prototype.toString: integers without a fraction, the shortest digits that read back the same, // an exponent from 1e21 up and below 1e-6 public static string Number(double value) { if (double.IsNaN(value) || double.IsInfinity(value)) throw new ArgumentException("JSON has no NaN or Infinity", nameof(value)); if (value == 0) return "0"; var abs = Math.Abs(value); if (abs >= 1e21 || abs < 1e-6) { var exponential = value.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e"); var at = exponential.IndexOf('e'); if (at < 0) return exponential; var mantissa = exponential[..at]; var exponent = int.Parse(exponential[(at + 1)..], CultureInfo.InvariantCulture); return $"{mantissa}e{(exponent < 0 ? "-" : "+")}{Math.Abs(exponent)}"; } var text = value.ToString("R", CultureInfo.InvariantCulture); if (!text.Contains('E')) return text; // "R" chose an exponent ECMAScript would not: written out in full return decimal.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); } } }