Webhook Security using HMAC
Within our Webhook configuration, there is a field called secret which clients can populate with a string. This string has a cryptographic hash function applied to it using the SHA256 algorithm that in turn produces a hash. The string is not stored by fenergo in plaintext and our service only has access to the hash value. That cryptographic string is combined with the full Body of the webhook message and a signature is calculated (another hash of the encrypted string and the message body). The resulting signature is stored in a header called x-fenx-signature that is included in every webhook message.
When the client receives a webhook message, they can take (the Body) + (their secret encrypted using sha256) and then calculate the signature on their side. This should match the value in the x-fenx-signature header. Once it does, the client knows the message can only have come from fenergo and the signature could only have been created by us as their secret is not available anywhere else.
If the x-fenx-signature does not match, the client should reject the message. HMAC itself implies that a secret is shared between two parties, so only Fenergo and the client have access to that secret. Clients can update their webhooks and replace their secrets at any time use the Webhook API.
Calculating the HMAC
Looking at the below sample webhook notification. The x-fenx-signature is listed and the Body of the notification is considered as everything from (and including) the opening curly brace down to line closing brace.
x-fenx-signature = sha256=0235388ABDFB20D6D8095CE7B1FFF069A6F57DF90B9810562FDDEB769D3FE7C4
{
"Id": "a3a6abfb-a6b3-51b6-a05b-f78624963dfd",
"TenantId": "45bf62ac-e4b1-4c16-ae32-d774cd18db6d",
"EventType": "entitydata:created",
"RelativeUrl": "entitydataquery/api/entity/3cd488d7-a580-4cfa-99a1-99ca30944d2d",
"Payload": {},
"When": "2022-07-23T11:02:15+00:00",
"CorrelationId": "aacf4084-2a1c-4c99-b6be-81164a4d8530",
"CausationId": "42797df0-16e1-430e-8636-9cf653903549"
}
The webhook which created this notification was configured with the "Secret": "Client Provided Secret". As a client, to determine that the message has originated from Fenergo you need to calculate the signature using this Secret and ensure that the signature you compute matches the one in the webhook message.
The HMAC signature is a standard Fenergo are following and not a custom implementation. As such there are plenty of reference implementations across the internet for different languages. Below we look at a C# and Python implementation for calculating a HMAC
C# Calculating the HMAC
/// Helper Method to test if a webhook signature is valid
/// [param name="webhookBody"]A byte[] of the body of a webhook [/param]
/// [param name="sharedSecret"]A byte[] of the shared secret.[/param]
/// [param name="passedSignatureForComparison"]signature from the header in the webhook call[/param]
public bool ValidateWebhookSignature(byte[] webhookBody, string passedSignatureForComparison)
{
byte[] sharedSecretasByteArray = Encoding.UTF8.GetBytes({Shared Secret from Encrypted Config / Settings File}});
using var hmacsha256 = new HMACSHA256(sharedSecretasByteArray);
var hash = hmacsha256.ComputeHash(webhookBody);
var hashString = "sha256=" + BitConverter
.ToString(hash)
.Replace("-", "");
//Test if the computed signature matches the one in the webhook.
return hashString.Equals(passedSignatureForComparison);
}
There are 3 key pieces of information here:
- Note the
"webhookBody"encoded as a byte[] array, passed as a parameter to the method. - The
"sharedSecretasByteArray"encoded as a byte[] array - as suggested in code this is retrieved from a secure location such as an encrypted config file. - The signature from the webhook message header, sent in for comparison
"passedSignatureForComparison". The method above returns True if the signatures match and False if the signatures do not match.
Calculating the HMAC in Python
import hmac
import hashlib
def ValidateWebhookSignature(webhookBytes, passedSignatureForComparison):
signaturesMatch = False
sharedSecret = 'Client Provided Secret' # Defined as a simple string - Should be sourced from a secure location.
sharedSecret_bytes= bytes(sharedSecret, 'utf-8')
calculatedSignature = hmac.new(sharedSecret_bytes, webhookBytes , hashlib.sha256).hexdigest()
if calculatedSignature.upper() == passedSignatureForComparison:
signaturesMatch = True
return signaturesMatch
body = '{"Id":"a3a6abfb-a6b3-51b6-a05b-f78624963dfd","TenantId":"45bf62ac-e4b1-4c16-ae32-d774cd18db6d","EventType":"entitydata:created","RelativeUrl":"entitydataquery/api/entity/3cd488d7-a580-4cfa-99a1-99ca30944d2d","Payload":{},"When":"2022-07-23T11:02:15+00:00","CorrelationId":"aacf4084-2a1c-4c99-b6be-81164a4d8530","CausationId":"42797df0-16e1-430e-8636-9cf653903549"}'
signatureFromWebhook = '0235388ABDFB20D6D8095CE7B1FFF069A6F57DF90B9810562FDDEB769D3FE7C4'
print('Signatures Match:'+ str(ValidateWebhookSignature(bytes(body, 'utf-8'), signatureFromWebhook)))
The same 3 key pieces of information are here in the Python Code:
"webhookBody"The webhook body which gets encoded as a byte[] array.- The
"sharedSecret"gets encoded as a byte[] array - as suggested in code this should be retrieved from a secure location such as an encrypted config file. - The
"signatureFromWebhook"from the message header, sent in for comparison. The method above returns True if the signatures match and False if the signatures do not match.