Jason H
Jason H

Reputation: 5156

Unable to get Typeform Webhook Signature with C# to work

First, this question has been asked and answered here but it was specific to Ruby/PHP and whilst I have attempted to follow it and the guidance from Typeform themselves, I am unable to implement the Typeform-Signature Check in C#.

I have written an extension method to validate the Typeform Signature against the payload sent via the webhook. If the signature is valid, it returns the string (json) payload but if not it returns an error.

public static class HttpRequestExtensions {
    private const string SignatureHeader = "Typeform-Signature";
    private static readonly Encoding encoding = new UTF8Encoding ();

    public static async Task<Result<string>> ValidateAndRetrievePayload (this HttpRequestMessage request, string key) {
        var headerValue = request.GetHeaderValue (SignatureHeader);
        if (string.IsNullOrWhiteSpace (headerValue)) return Result.Failure<string> ($"'{SignatureHeader}' Header not found or empty.");

        var json = await request.Content.ReadAsStringAsync ();
        var payload = encoding.GetBytes (json);
        using (var hmac256 = new HMACSHA256 (encoding.GetBytes (key))) {
            var hashPayload = hmac256.ComputeHash (payload);
            var base64String = Convert.ToBase64String (hashPayload);
            var hashResult = $"sha256={base64String}";
            if (hashResult.Equals (headerValue)) return Result.Success (json);
            return Result.Failure<string> ($"'{SignatureHeader}' does not match. Header: `{headerValue}` | Hash: `{hashResult}`");
        }
    }
}

Based on other questions found on SO, I modified the method to run without encoding (see below) but still ended up in with the same result, the hashes are not matching.

public static class HttpRequestExtensions
{
    private const string SignatureHeader = "Typeform-Signature";

    public static async Task<Result<string>> ValidateAndRetrievePayload(this HttpRequestMessage request, string key)
    {
        var headerValue = request.GetHeaderValue(SignatureHeader);
        if (string.IsNullOrWhiteSpace(headerValue))
            return Result.Failure<string>($"'{SignatureHeader}' Header not found or empty.");

        var payload = await request.Content.ReadAsByteArrayAsync();
        var byteKey = GetBytes(key);
        using (var hmac256 = new HMACSHA256(byteKey))
        {
            var hashPayload = hmac256.ComputeHash(payload);
            var base64String = Convert.ToBase64String(hashPayload);
            var hashResult = $"sha256={base64String}";
            if (hashResult.Equals(headerValue))
                return Result.Success(await request.Content.ReadAsStringAsync());
            return Result.Failure<string>(
                $"'{SignatureHeader}' does not match. Header: `{headerValue}` | Hash: `{hashResult}`");
        }
    }

    private static byte[] GetBytes(string value)
    {
        var bytes = new byte[value.Length * sizeof(char)];
        Buffer.BlockCopy(value.ToCharArray(), 0, bytes, 0, bytes.Length);
        return bytes;
    }

    private static string GetString(byte[] bytes)
    {
        var chars = new char[bytes.Length / sizeof(char)];
        Buffer.BlockCopy(bytes, 0, chars, 0, bytes.Length);
        return new string(chars);
    }
}

Upvotes: 0

Views: 1549

Answers (5)

Ethan Anderson
Ethan Anderson

Reputation: 3

Here is how I did it using streams from the HttpRequest object.

// main
var payload = await GetPayloadAsString(context.Request);
var encodedSignature = GetEncodedSignature(_typeformSecret, payload);

// Helper methods
private static async Task<string> GetPayloadAsString(HttpRequest request)
{
    var stream = request.Body; // At the beginning it holding original request stream                    
    var originalReader = new StreamReader(stream);
    var originalContent = await originalReader.ReadToEndAsync(); // Reading first request

    return originalContent;
}

private static string GetEncodedSignature(string secret, string payload)
{
    var keyBytes = Encoding.UTF8.GetBytes(secret);
    var byteArray = Encoding.UTF8.GetBytes(payload);
    var hash = new HMACSHA256(keyBytes).ComputeHash(byteArray);

    return "sha256=" + Convert.ToBase64String(hash);
}

Upvotes: 0

Blieque
Blieque

Reputation: 675

If you're having trouble with this when testing your webhook service, bear in mind that the JSON body sent by Typeform is minified and ends with a single Unix new-line (0x0a) character.

This is of little relevance when sending the request body straight into the HMACSHA256 instance, but is important if you're trying to validate your code with an inline JSON string. It's particularly confusing that Typeform shows formatted JSON output in the webhooks admin UI.

Signature verification of a string in C# could look like this:

using System;

class Program {
  static void Main(string[] args) {
    var key = "secret-key";
    // Note:  
    // - No spaces after `:`, `,`, etc.
    // - No indentation.
    // - No newlines except for a single line feed character on the end.
    var body = "{\"event_id\":\"01FCR2NZ5NNGBPTWEJXV0FR5V3\",\"event_type\":\"...\"}\u000a";
    
    // Convert strings to UTF-8 byte arrays.
    var keyBytes = Text.Encoding.UTF8.GetBytes(key);
    var bodyBytes = Text.Encoding.UTF8.GetBytes(body);

    string signature;
    using (var hmac = new Security.Cryptography.HMACSHA256(keyBytes))
    {
      // Calculate a hash and convert it to a base-64 string.
      var computedHashBytes = hmac.ComputeHash(bodyBytes);
      var computedHashBase64 = Convert.ToBase64String(computedHashBytes);
      signature = $"sha256={computedHashBase64}";    
    }
    Console.WriteLine(signature);
  }
}

Upvotes: 3

Jason H
Jason H

Reputation: 5156

Here is the solution I ended up using. There are aspects from most answers on this question that ended up providing leads to solving the issue.

public async Task<bool> ValidateSignature(HttpRequest request, Signature signatureData)
{
    var headerValue = request.Headers[signatureData.HeaderKeyName];
    var keyBytes = Encoding.UTF8.GetBytes(signatureData.Secret);
    var messageBytes = Encoding.UTF8.GetBytes(await request.ReadAsStringAsync());
    byte[] hashMessage;

    switch (signatureData.HashType)
    {
        case HashType.HMAC_Sha1:
            hashMessage = new HMACSHA1(keyBytes).ComputeHash(messageBytes);
            break;

        case HashType.HMAC_Sha256:
            hashMessage = new HMACSHA256(keyBytes).ComputeHash(messageBytes);
            break;

        case HashType.HMAC_Sha384:
            hashMessage = new HMACSHA384(keyBytes).ComputeHash(messageBytes);
            break;

        case HashType.HMAC_Sha512:
            hashMessage = new HMACSHA512(keyBytes).ComputeHash(messageBytes);
            break;

        case HashType.HMAC_MD5:
            hashMessage = new HMACMD5(keyBytes).ComputeHash(messageBytes);
            break;

        default:
            throw new ArgumentOutOfRangeException(nameof(signatureData), "Hash type not currently supported.");
    }

    var builder = new StringBuilder();
    foreach (var t in hashMessage) builder.Append(t.ToString("x2"));

    var finalValue = builder.ToString();
    if (signatureData.HasPrefix) finalValue = $"{signatureData.PrefixValue}{builder}";

    return finalValue == headerValue;
}

Upvotes: 0

Ander
Ander

Reputation: 1

Try to use UTF encoder instead of the original ASCII one proposed:

private static string CreateToken(string message)
{
    var encoding = new System.Text.UTF8Encoding();
    byte[] keyByte = encoding.GetBytes(SECRET);
    byte[] messageBytes = encoding.GetBytes(message);
    using (var hmacsha256 = new HMACSHA256(keyByte))
    {
        byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
        return Convert.ToBase64String(hashmessage);
    }
}

private bool IsValid(string jsonRequest)
{
    string typeFormSig = Request.Headers["Typeform-Signature"];
    string generatedSig = $"sha256={CreateToken(jsonRequest)}";
    _logger.LogInformation($"SIGNATURE STUFF: sec: {SECRET} typeform: {typeFormSig}  MyGen: {generatedSig}");
    return (typeFormSig == generatedSig);
}

Upvotes: 0

treendy
treendy

Reputation: 473

I hope this helps... im new to TypeForm API also, but i have the following code which is working... very messy, i havent got around to refactoring it yet... it works on one of my forms fine, but for some reason, when i use a different TypeForm account its not working (even though i have set the secret the same).... this is why i havent refactored it yet...

But just sharing, because it works for my first account and may be of use (my 2nd account is having the same issue where result isnt matching... if i find out why, i will let you know here and update this answer

    private bool IsValid(string jsonRequest)
    {
        string typeFormSig = Request.Headers["Typeform-Signature"];
        string generatedSig = $"sha256={CreateToken(jsonRequest)}";
        _logger.LogInformation($"SIGNATURE STUFF: sec: {SECRET} typeform: {typeFormSig}  MyGen: {generatedSig}");
        return (typeFormSig == generatedSig);
    }

    private static string CreateToken(string message)
    {
        var encoding = new System.Text.ASCIIEncoding();
        byte[] keyByte = encoding.GetBytes(SECRET);
        byte[] messageBytes = encoding.GetBytes(message);
        using (var hmacsha256 = new HMACSHA256(keyByte))
        {
            byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
            return Convert.ToBase64String(hashmessage);
        }
    }

And this is how i get the json string:

[HttpPost("")]
    public async Task<IActionResult> Receive()
    {
        using (var reader = new StreamReader(Request.Body))
        {
            string jsonRequest = await reader.ReadToEndAsync();

Upvotes: 0

Related Questions