Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Hmac verification with flask in Python (with reference in PHP and RUBY)

I have been working on a way to implement HMAC verification in python with flask for the selly.gg merchant website.

So selly's dev documentation give these following examples to verify HMAC signatures (in PHP and ruby): https://developer.selly.gg/?php#signing-validating (code below:)

PHP:

<?php
        $signature = hash_hmac('sha512', json_encode($_POST), $secret);
        if hash_equals($signature, $signatureFromHeader) {
            // Webhook is valid 
        }
?>

RUBY:

signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha512'), secret, payload.to_json)
is_valid_signature = ActiveSupport::SecurityUtils.secure_compare(request.headers['X-Selly-Signature'], signature)

So, so far what I could figure out: They don't encode with base64 (like shopify and others do), it uses SHA-512, it encodes the secret code alongside json response data and finally the request header is 'X-Selly-Signature'

I've made the following code so far (based on shopify's code for HMAC signing https://help.shopify.com/en/api/getting-started/webhooks):

SECRET = "secretkeyhere"
def verify_webhook(data, hmac_header):
    digest = hmac.new(bytes(SECRET, 'ascii'), bytes(json.dumps(data), 'utf8'), hashlib.sha512).hexdigest()
    return hmac.compare_digest(digest, hmac_header)
try:
    responsebody = request.json #line:22
    status = responsebody['status']#line:25
except Exception as e:
    print(e)
    return not_found()
print("X Selly sign: " + request.headers.get('X-Selly-Signature'))
verified = verify_webhook(responsebody, request.headers.get('X-Selly-Signature'))
print(verified)

However selly has a webhook simulator, and even with the proper secret key and valid requests, the verify_webhook will always return False. I tried contacting Selly support, but they couldn't help me more than that

You can test the webhook simulator at the following address: https://selly.io/dashboard/{your account}/developer/webhook/simulate

like image 439
Tom Avatar asked Oct 15 '22 13:10

Tom


1 Answers

You're nearly right except that you don't need to json.dumps the request data. This will likely introduce changes into output, such as changes to formatting, that won't match the original data meaning the HMAC will fail.

E.g.

{"id":"fd87d909-fbfc-466c-964a-5478d5bc066a"}

is different to:

{
  "id":"fd87d909-fbfc-466c-964a-5478d5bc066a"
}

which is actually:

{x0ax20x20"id":"fd87d909-fbfc-466c-964a-5478d5bc066a"x0a}

A hash will be completely different for the two inputs.

See how json.loads and json.dumps will modify the formatting and therefore the hash:

http_data = b'''{
    "id":"fd87d909-fbfc-466c-964a-5478d5bc066a"
}
'''
print(http_data)
h = hashlib.sha512(http_data).hexdigest()
print(h)
py_dict = json.loads(http_data) # deserialise to Python dict
py_str = json.dumps(py_dict) # serialise to a Python str
py_bytes = json.dumps(py_dict).encode('utf-8') # encode to UTF-8 bytes
print(py_str)
h2 = hashlib.sha512(py_bytes).hexdigest()
print(h2)

Output:

b'{\n    "id":"fd87d909-fbfc-466c-964a-5478d5bc066a"\n}\n'
364325098....
{"id": "fd87d909-fbfc-466c-964a-5478d5bc066a"}
9664f687a....

It doesn't help that Selly's PHP example shows something similar. In fact, the Selly PHP example is useless as the data won't be form encoded anyway, so the data won't be in $_POST!

Here's my little Flask example:

import hmac
import hashlib
from flask import Flask, request, Response

app = Flask(__name__)

php_hash = "01e5335ed340ef3f211903f6c8b0e4ae34c585664da51066137a2a8aa02c2b90ca13da28622aa3948b9734eff65b13a099dd69f49203bc2d7ae60ebee9f5d858"
secret = "1234ABC".encode("ascii") # returns a byte object

@app.route("/", methods=['POST', 'GET'])
def selly():
    request_data = request.data # returns a byte object
    hm = hmac.new(secret, request_data, hashlib.sha512)
    sig = hm.hexdigest()

    resp = f"""req: {request_data}
    sig: {sig}
    match: {sig==php_hash}"""

    return Response(resp, mimetype='text/plain')

app.run(debug=True)

Note the use of request.data to get the raw byte input and the simple use of encode on the secret str to get the encoded bytes (instead of using the verbose bytes() instantiation).

This can be tested with:

curl -X "POST" "http://localhost:5000/" \
 -H 'Content-Type: text/plain; charset=utf-8' \
 -d "{\"id\":\"fd87d909-fbfc-466c-964a-5478d5bc066a\"}"

I also created a bit of PHP to validate both languages create the same result:

<?php
    header('Content-Type: text/plain');
    $post = file_get_contents('php://input');
    print $post;
    $signature = hash_hmac('sha512', $post, "1234ABC");
    print $signature;
?>
like image 121
Alastair McCormack Avatar answered Oct 31 '22 14:10

Alastair McCormack