2

I'm implementing an exchange which has private endpoints which are protected with a signature. The first code looks better but it results into an invalid signature due to the fact that it doesn't lexicographically sort it. How do I fix it? The second code works fine btw.

Broken code

public ValueTask SubscribeToPrivateAsync()
{
    var request = JsonSerializer.Serialize(new MexcSubPersonalPayload(_apiKey, _apiSecret));

    return SendAsync(request);
}

internal class MexcSubPersonalPayload
{
    private readonly string _apiSecret;

    public MexcSubPersonalPayload(string apiKey, string apiSecret)
    {
        ApiKey = apiKey;
        _apiSecret = apiSecret;
    }

    [JsonPropertyName("op")]
    [JsonPropertyOrder(1)]
    public string Operation => "sub.personal";

    [JsonPropertyName("api_key")]
    [JsonPropertyOrder(2)]
    public string ApiKey { get; }

    [JsonPropertyName("sign")]
    [JsonPropertyOrder(3)]
    public string Signature => Sign(ToString());

    [JsonPropertyName("req_time")]
    [JsonPropertyOrder(4)]
    public long RequestTime => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    private string Sign(string payload)
    {
        if (string.IsNullOrWhiteSpace(payload))
        {
            return string.Empty;
        }

        using var md5 = MD5.Create();
        var sourceBytes = Encoding.UTF8.GetBytes(payload);
        var hash = md5.ComputeHash(sourceBytes);
        return Convert.ToHexString(hash);
    }

    public override string ToString()
    {
        return $"api_key={HttpUtility.UrlEncode(ApiKey)}&req_time={RequestTime}&op={Operation}&api_secret={HttpUtility.UrlEncode(_apiSecret)}";
    }
}

Working code

public ValueTask SubscribeToPrivateAsync()
{
    var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    var @params = new Dictionary<string, object>
    {
        { "api_key", _apiKey },
        { "req_time", now },
        { "op", "sub.personal" }
    };

    var signature = SignatureHelper.Sign(SignatureHelper.GenerateSign(@params, _apiSecret));
    var request = new PersonalRequest("sub.personal", _apiKey, signature, now);

    return SendAsync(request);
}

internal static class SignatureHelper
{
    public static string GenerateSign(IDictionary<string, object> query, string apiSecret)
    {
        // Lexicographic order
        query = new SortedDictionary<string, object>(query, StringComparer.Ordinal);

        var sb = new StringBuilder();

        var queryParameterString = string.Join("&",
            query.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value.ToString()))
                .Select(kvp => $"{kvp.Key}={HttpUtility.UrlEncode(kvp.Value.ToString())}"));
        sb.Append(queryParameterString);

        if (sb.Length > 0)
        {
            sb.Append('&');
        }

        sb.Append("api_secret=").Append(HttpUtility.UrlEncode(apiSecret));

        return sb.ToString();
    }

    public static string Sign(string source)
    {
        using var md5 = MD5.Create();
        var sourceBytes = Encoding.UTF8.GetBytes(source);
        var hash = md5.ComputeHash(sourceBytes);
        return Convert.ToHexString(hash);
    }
}
nop
  • 4,711
  • 6
  • 32
  • 93
  • 1
    Why are your [`JsonPropertyOrder`](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-customize-properties#configure-the-order-of-serialized-properties) attributes not in lexicographical order? Defining property order via this attribute is currently the only way to control property order during serialization with System.Text.Json as its [contract information is private](https://stackoverflow.com/q/58926112/3744182). – dbc Jun 12 '22 at 14:18
  • 1
    If you can't use `JsonPropertyOrder` to configure the order as required (e.g. because you require different orders in different contexts) you could either 1) Use a custom converter to inject a DTO with the properties in the required order, or 2) Serialize to `JsonObject` and sort that (recursively if needed). Do either 1 or 2 meet your needs? – dbc Jun 12 '22 at 14:19
  • @dbc, yes, probably a custom converter suits it more. But it would be nice to hear both solutions. – nop Jun 12 '22 at 14:32

1 Answers1

2

Update for .NET 7

In .NET 7 and later you may use a typeInfo modifier to lexicographically sort the JSON properties of your your type's JSON contract.

First, define the following static methods returning an Action<JsonTypeInfo>:

public static partial class JsonExtensions
{
    public static Action<JsonTypeInfo> AlphabetizeProperties(Type type)
    {
        return typeInfo =>
        {
            if (typeInfo.Kind != JsonTypeInfoKind.Object || !type.IsAssignableFrom(typeInfo.Type))
                return;
            AlphabetizeProperties()(typeInfo);
        };
    }
    
    public static Action<JsonTypeInfo> AlphabetizeProperties()
    {
        return static typeInfo =>
        {
            if (typeInfo.Kind != JsonTypeInfoKind.Object)
                return;
            var properties = typeInfo.Properties.OrderBy(p => p.Name, StringComparer.Ordinal).ToList();
            typeInfo.Properties.Clear();
            for (int i = 0; i < properties.Count; i++)
            {
                properties[i].Order = i;
                typeInfo.Properties.Add(properties[i]);
            }
        };
    }
}

And now you will be able to serialize as follows:

var payload = new MexcSubPersonalPayload(_apiKey, _apiSecret);
var options = new JsonSerializerOptions
{
    TypeInfoResolver = new DefaultJsonTypeInfoResolver
    {
        Modifiers = { JsonExtensions.AlphabetizeProperties() },
    },
};
var request = JsonSerializer.Serialize(payload, options);

Notes:

  • Overflow properties stored in an extension data dictionary will not be alphabetized using this method. System.Text.Json seems to always place them last.

    This does not apply to your MexcSubPersonalPayload model.

  • This approach does not apply to dictionary serialization. To order the dictionary properties when serializing, a custom converter could be used.

    This also does not apply to your MexcSubPersonalPayload model.

.NET 7 demo fiddle here.

Original Answer for .NET 6 and earlier

As explained in Configure the order of serialized properties, as of .NET 6 the only mechanism that System.Text.Json provides to control property order during serialization is to apply [JsonPropertyOrder] attributes to your model [1]. You have already done so -- but the order specified is not lexicographic. So what are your options?

Firstly, you could modify your model so that the [JsonPropertyOrder] are in lexicographic order:

internal class MexcSubPersonalPayload
{
    private readonly string _apiSecret;

    public MexcSubPersonalPayload(string apiKey, string apiSecret)
    {
        ApiKey = apiKey;
        _apiSecret = apiSecret;
    }

    [JsonPropertyName("op")]
    [JsonPropertyOrder(2)]
    public string Operation => "sub.personal";

    [JsonPropertyName("api_key")]
    [JsonPropertyOrder(1)]
    public string ApiKey { get; }

    [JsonPropertyName("sign")]
    [JsonPropertyOrder(4)]
    public string Signature => Sign(ToString());

    [JsonPropertyName("req_time")]
    [JsonPropertyOrder(3)]
    public long RequestTime => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    // Remainder unchanged

Demo fiddle #2 here.

Secondly, if you cannot modify your [JsonPropertyOrder] attributes (because e.g. you require a different order in a different context), then as long as you are using .NET 6 or later, you could serialize your model to an intermediate JsonNode hierarchy and sort its properties before finally formatting as a JSON string. To do this, first introduce the following extension methods:

public static partial class JsonExtensions
{
    public static JsonNode? SortProperties(this JsonNode? node, bool recursive = true) => node.SortProperties(StringComparer.Ordinal, recursive);

    public static JsonNode? SortProperties(this JsonNode? node, IComparer<string> comparer, bool recursive = true)
    {
        if (node is JsonObject obj)
        {
            var properties = obj.ToList();
            obj.Clear();
            foreach (var pair in properties.OrderBy(p => p.Key, comparer))
                obj.Add(new (pair.Key, recursive ? pair.Value.SortProperties(comparer, recursive) : pair.Value));
        }
        else if (node is JsonArray array)
        {
            foreach (var n in array)
                n.SortProperties(comparer, recursive);
        }
        return node;
    }
}

And then you will be able to do:

public string GenerateRequest() => JsonSerializer.SerializeToNode(new MexcSubPersonalPayload(_apiKey, _apiSecret))
    .SortProperties(StringComparer.Ordinal)!
    .ToJsonString();

Demo fiddle #3 here.

Thirdly, you could create a custom JsonConverter<MexcSubPersonalPayload> that serializes the properties in the correct order, e.g. by mapping MexcSubPersonalPayload to a DTO, then serializing the DTO. First define:

class MexcSubPersonalPayloadConverter : JsonConverter<MexcSubPersonalPayload>
{
    record MexcSubPersonalPayloadDTO(
        [property:JsonPropertyName("api_key"), JsonPropertyOrder(1)] string ApiKey, 
        [property:JsonPropertyName("op"), JsonPropertyOrder(2)] string Operation, 
        [property:JsonPropertyName("req_time"), JsonPropertyOrder(3)]long RequestTime, 
        [property:JsonPropertyName("sign"), JsonPropertyOrder(4)]string Signature);

    public override void Write(Utf8JsonWriter writer, MexcSubPersonalPayload value, JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, new MexcSubPersonalPayloadDTO(value.ApiKey, value.Operation, value.RequestTime, value.Signature), options);
    public override MexcSubPersonalPayload Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotImplementedException();
}

And then do:

public string GenerateRequest() => JsonSerializer.Serialize(new MexcSubPersonalPayload(_apiKey, _apiSecret), new JsonSerializerOptions { Converters = { new MexcSubPersonalPayloadConverter() }});

Demo fiddle #4 here.


[1] With Json.NET one could override property order via a custom contract resolver, but as of .NET 6 System.Text.Json lacks this flexibility as its contract information is private.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Thank you for your answer! This results into `{"api_key":"mx0ETo1oU5o7v6M7dH","op":"sub.personal","req_time":1655052021218,"sign":"efbd96be2362ce18557b573b31eb72c7"}` which results into an invalid signature. The working code outputs the following: `{"api_key":"mx0ETo1oU5o7v6M7dH","sign":"e3a206c4bf9af6c365ed3969730d533a","req_time":1655052021100,"op":"sub.personal"}` – nop Jun 12 '22 at 16:41
  • Oh I think I know what the issue is. It should be sorted in lexicographical order before it gets the signature (before `Sign(...)` call) – nop Jun 12 '22 at 16:43
  • @nop - your broken code didn't show the JSON being passed into `Sign()`, just a URL-encoded string with some properties. So I couldn't test or demo that. If you want to pass your JSON into `Sign()` please [edit](https://stackoverflow.com/posts/72592246/edit) your original question to show what you need to do -- i.e. a [mcve] where you need to generate the sorted JSON for signing. – dbc Jun 12 '22 at 16:47
  • I'm not sure tbh. https://github.com/mxcdevelop/mexc-api-demo/blob/7991f8a3a17e6ba5dc2cf55133532ab145b0c878/go/ws/private/privateWs.go this is a Go working sample, it looks like they are doing the same as in your code but it doesn't work (invalid signature) – nop Jun 12 '22 at 16:49
  • @nop - Incidentally if you are trying to recursively serialize to JSON to generate a signature while inside JSON serialization you are going to have bigger problems because System.Text.Json doesn't support recursive self-serialization easily. (Also, I don't know Go so I can't compare that code with mine.) – dbc Jun 12 '22 at 16:51
  • 1
    got it working. `ToString` had to be as following: `return $"api_key={ApiKey}&op={Operation}&req_time={RequestTime}&api_secret={_apiSecret}";` – nop Jun 12 '22 at 16:53
  • @nop - Incidentally, since you timestamp your objects with `DateTimeOffset.UtcNow.ToUnixTimeMilliseconds` it's difficult for me to know whether the generated signatures are correct. If you are going to create a [mcve] maybe you could use a dummy fixed value so we can see consistent signatures? – dbc Jun 12 '22 at 16:53
  • https://github.com/mxcdevelop/mexc-api-demo/blob/7991f8a3a17e6ba5dc2cf55133532ab145b0c878/go/ws/private/privateWs.go#L35 take a look at this one. Since `Sign(...)` is being called with what's in `ToString`, the order doesn't really matter as soon as what's in `ToString` is correct. Basically I made it like their Go example – nop Jun 12 '22 at 16:55
  • here is the working code btw: https://pastebin.com/qtVpMjhc Thank you very much! You can update your answer if you'd like to – nop Jun 12 '22 at 17:00
  • @nop - so in the end, did you need to sort the JSON properties at all? Your working code doesn't seem to do so. – dbc Jun 12 '22 at 17:05
  • nope. I had to manually sort what's in `ToString()`. If there has to be any sorting, it should be applied on the `ToString()` – nop Jun 12 '22 at 17:07
  • 1
    @nop - Oh well. I did answer the general question *How to override the property order in System.Text.Json* though, so I guess it's worth leaving the answer here, even if you didn't actually need that. – dbc Jun 12 '22 at 17:10