Search code examples
c#sha256github-webhookgithub-appsmee

Verify Github webhook signature


I'm trying to build a Github app backed by a handler in C#. In order to test the code on your local device, you can use smee.io. In the code you use the .NET SmeeClient which establishes a connection to the server and downstreams payloads to your .NET application. In Probot it's setup by default like this, but the code is spread over several repositories. I'm trying to build something similar in .NET now.

The code is hosted here, the modifications to verify the signature are here.

I've written a small code snippet to simplify the verification of the signature during testing:

var receivedFromGithub = "sha256=4eadd8e53ec9e1377084c243f7bc103de7dd7171948d2acee3d06c20154dceb5";
var configuredSecret = "[not-going-to-share-this]";
var rawBody = """
    {"event":"issues","payload":{"action":"opened","issue":{"url":"https://api.github.com/repos/Example/tsst/issues/24","repository_url":"https://api.github.com/repos/Example/tsst","labels_url":"https://api.github.com/repos/Example/tsst/issues/24/labels{/name}","comments_url":"https://api.github.com/repos/Example/tsst/issues/24/comments","events_url":"https://api.github.com/repos/Example/tsst/issues/24/events","html_url":"https://github.com/Example/tsst/issues/24","id":2096820864,"node_id":"I_kwDOLHq9zc58-vKA","number":24,"title":"Test 23","user":{"login":"Example","id":9629574,"node_id":"MDQ6VXNlcjk2Mjk1NzQ=","avatar_url":"https://avatars.githubusercontent.com/u/9629574?v=4","gravatar_id":"","url":"https://api.github.com/users/Example","html_url":"https://github.com/Example","followers_url":"https://api.github.com/users/Example/followers","following_url":"https://api.github.com/users/Example/following{/other_user}","gists_url":"https://api.github.com/users/Example/gists{/gist_id}","starred_url":"https://api.github.com/users/Example/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Example/subscriptions","organizations_url":"https://api.github.com/users/Example/orgs","repos_url":"https://api.github.com/users/Example/repos","events_url":"https://api.github.com/users/Example/events{/privacy}","received_events_url":"https://api.github.com/users/Example/received_events","type":"User","site_admin":false},"labels":[],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":0,"created_at":"2024-01-23T19:19:44Z","updated_at":"2024-01-23T19:19:44Z","closed_at":null,"author_association":"OWNER","active_lock_reason":null,"body":null,"reactions":{"url":"https://api.github.com/repos/Example/tsst/issues/24/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/Example/tsst/issues/24/timeline","performed_via_github_app":null,"state_reason":null},"repository":{"id":746241485,"node_id":"R_kgDOLHq9zQ","name":"tsst","full_name":"Example/tsst","private":false,"owner":{"login":"Example","id":9629574,"node_id":"MDQ6VXNlcjk2Mjk1NzQ=","avatar_url":"https://avatars.githubusercontent.com/u/9629574?v=4","gravatar_id":"","url":"https://api.github.com/users/Example","html_url":"https://github.com/Example","followers_url":"https://api.github.com/users/Example/followers","following_url":"https://api.github.com/users/Example/following{/other_user}","gists_url":"https://api.github.com/users/Example/gists{/gist_id}","starred_url":"https://api.github.com/users/Example/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Example/subscriptions","organizations_url":"https://api.github.com/users/Example/orgs","repos_url":"https://api.github.com/users/Example/repos","events_url":"https://api.github.com/users/Example/events{/privacy}","received_events_url":"https://api.github.com/users/Example/received_events","type":"User","site_admin":false},"html_url":"https://github.com/Example/tsst","description":null,"fork":false,"url":"https://api.github.com/repos/Example/tsst","forks_url":"https://api.github.com/repos/Example/tsst/forks","keys_url":"https://api.github.com/repos/Example/tsst/keys{/key_id}","collaborators_url":"https://api.github.com/repos/Example/tsst/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/Example/tsst/teams","hooks_url":"https://api.github.com/repos/Example/tsst/hooks","issue_events_url":"https://api.github.com/repos/Example/tsst/issues/events{/number}","events_url":"https://api.github.com/repos/Example/tsst/events","assignees_url":"https://api.github.com/repos/Example/tsst/assignees{/user}","branches_url":"https://api.github.com/repos/Example/tsst/branches{/branch}","tags_url":"https://api.github.com/repos/Example/tsst/tags","blobs_url":"https://api.github.com/repos/Example/tsst/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/Example/tsst/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/Example/tsst/git/refs{/sha}","trees_url":"https://api.github.com/repos/Example/tsst/git/trees{/sha}","statuses_url":"https://api.github.com/repos/Example/tsst/statuses/{sha}","languages_url":"https://api.github.com/repos/Example/tsst/languages","stargazers_url":"https://api.github.com/repos/Example/tsst/stargazers","contributors_url":"https://api.github.com/repos/Example/tsst/contributors","subscribers_url":"https://api.github.com/repos/Example/tsst/subscribers","subscription_url":"https://api.github.com/repos/Example/tsst/subscription","commits_url":"https://api.github.com/repos/Example/tsst/commits{/sha}","git_commits_url":"https://api.github.com/repos/Example/tsst/git/commits{/sha}","comments_url":"https://api.github.com/repos/Example/tsst/comments{/number}","issue_comment_url":"https://api.github.com/repos/Example/tsst/issues/comments{/number}","contents_url":"https://api.github.com/repos/Example/tsst/contents/{+path}","compare_url":"https://api.github.com/repos/Example/tsst/compare/{base}...{head}","merges_url":"https://api.github.com/repos/Example/tsst/merges","archive_url":"https://api.github.com/repos/Example/tsst/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/Example/tsst/downloads","issues_url":"https://api.github.com/repos/Example/tsst/issues{/number}","pulls_url":"https://api.github.com/repos/Example/tsst/pulls{/number}","milestones_url":"https://api.github.com/repos/Example/tsst/milestones{/number}","notifications_url":"https://api.github.com/repos/Example/tsst/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/Example/tsst/labels{/name}","releases_url":"https://api.github.com/repos/Example/tsst/releases{/id}","deployments_url":"https://api.github.com/repos/Example/tsst/deployments","created_at":"2024-01-21T13:50:49Z","updated_at":"2024-01-21T13:50:49Z","pushed_at":"2024-01-21T13:50:49Z","git_url":"git://github.com/Example/tsst.git","ssh_url":"[email protected]:Example/tsst.git","clone_url":"https://github.com/Example/tsst.git","svn_url":"https://github.com/Example/tsst","homepage":null,"size":0,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":23,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":23,"watchers":0,"default_branch":"main"},"sender":{"login":"Example","id":9629574,"node_id":"MDQ6VXNlcjk2Mjk1NzQ=","avatar_url":"https://avatars.githubusercontent.com/u/9629574?v=4","gravatar_id":"","url":"https://api.github.com/users/Example","html_url":"https://github.com/Example","followers_url":"https://api.github.com/users/Example/followers","following_url":"https://api.github.com/users/Example/following{/other_user}","gists_url":"https://api.github.com/users/Example/gists{/gist_id}","starred_url":"https://api.github.com/users/Example/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Example/subscriptions","organizations_url":"https://api.github.com/users/Example/orgs","repos_url":"https://api.github.com/users/Example/repos","events_url":"https://api.github.com/users/Example/events{/privacy}","received_events_url":"https://api.github.com/users/Example/received_events","type":"User","site_admin":false},"installation":{"id":46383717,"node_id":"MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDYzODM3MTc="}}}
    """;




// Re-compute the hash value using the secret as the key.
byte[] key = Encoding.UTF8.GetBytes(configuredSecret);
using (var hmac = new HMACSHA256(key))
{
    {
        // Use secret to re-compute the hash of the payload.
        byte[] computedSig = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
        // Notice the hash needs to be in HEX before comparison
        Console.WriteLine("Computed signature: {0}", ToHexString(computedSig));
    }
}

static string ToHexString(byte[] bytes)
{
    var builder = new StringBuilder(bytes.Length * 2);
    foreach (byte b in bytes)
    {
        builder.AppendFormat("{0:x2}", b);
    }

    return builder.ToString();
}

However, no matter what encoding (UTF-8, ASCII) or SHA crypt I use, I always get other results. I litterally copied the payload from the smee-channel-url when it appears.

How can I correctly verify this signature from github in c#? Do I need the request body to verify the signature?

EDIT

Okay, according to this documentation link, the following code snippet gives the desired result:

var rawBody = "Hello, World!";
var signatureSha256 = "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
var secret = "It's a Secret to Everybody";
if (!string.IsNullOrEmpty(secret))
{
    var keyBytes = Encoding.UTF8.GetBytes(secret);
    var bodyBytes = Encoding.UTF8.GetBytes(rawBody);

    var hash = HMACSHA256.HashData(keyBytes, bodyBytes);
    var hashHex = Convert.ToHexString(hash);
    var expectedHeader = hashHex.ToLower(CultureInfo.InvariantCulture);
    var correct = (signatureSha256.ToString() == expectedHeader); // true
}

EDIT 2

Okay, with the latest state of the code, I managed to get the correct signature for basic issue titles, but it fails when the title contains special characters.

So for the snippet:

var rawBody = """
    {"title": "Test 67 = with - more + characters : and . colons"}
    """;

using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms);
JsonDocument.Parse(rawBody!).WriteTo(writer);

await writer.FlushAsync();
ms.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(ms);
var jsonFormatted = await reader.ReadToEndAsync();

Console.WriteLine(jsonFormatted);
// {"title":"Test 67 = with - more \u002B characters : and . colons"}

If I can find a solution to this, the problem is solved.


Solution

  • Problem was the formatting of the JSON. When I read the payload from the SMEE channel, the JSON is formatted and not the same as the JSON that was actually sent.

    Solution was to format the JSON using

    JsonConvert.SerializeObject(JsonConvert.DeserializeObject(data.Body.ToString()));
    

    Now the signature matches the payload.