Open mphasize opened 3 years ago
Based on the documentation provided, I came up with this implementation in Node.js:
exports.validateJaasSignature = (req, secret) => {
const header = req.headers["x-jaas-signature"];
const signatureElements = header.split(",");
let timestamp = null;
let signature = null;
signatureElements.forEach((element) => {
const key = element.substring(0, element.indexOf("="));
const value = element.substring(element.indexOf("=") + 1); // we can't use split as the value can contain (=) characters
switch (key) {
case "t":
timestamp = value;
break;
case "v1":
signature = value;
break;
}
});
if (timestamp === null || signature === null) return false; // signature doesn't match expected format
const hmac = crypto.createHmac("sha256", secret);
const signedPayload = timestamp.concat(".", JSON.stringify(req.body));
hmac.update(signedPayload);
const generatedSignature = hmac.digest("base64");
console.log("Compare JaaS signature:", req.headers["x-jaas-signature"], generatedSignature);
// if comparison checks out
return true;
};
Now, this generates the correct signature MOST of the time, but NOT ALL of the time. I tried to use req.rawBody.toString()
(instead of JSON.stringify) for the signedPayload, but that produces exactly the same generated signature.
Any ideas what could cause the mismatch in the signature?
@mphasize I made it work as expected by replacing hmac.update(signedPayload);
with : hmac.update(new TextEncoder("utf-8").encode(signedPayload));
in your codebase.
Also, you need to send a 200 response after receiving the webhook, otherwise the webhook will be retried indefinitely (with the same idempotency key)
We will update the documentation to specify that the hmac message should be the utf-8 encoded byte array of the string. Very sorry for the trouble this had caused.
Thanks for taking a look at this, @horymury!
Also thank you for submitting your first PR and contribution, @mphasize awesome stuff! 🎉
@horymury Thank you for looking into it, I will try your solution.
@horymury so I tried to implement your solution like this:
// Generate the signature of JSON body for comparison
const hmac = crypto.createHmac("sha256", secret);
const signedPayload = timestamp.concat(".", JSON.stringify(req.body));
const signedPayloadUTF8 = new TextEncoder("utf-8").encode(signedPayload);
hmac.update(signedPayloadUTF8);
const generatedSignature = hmac.digest("base64");
// Generate second signature of raw body for comparison
const rawBodyString = req.rawBody.toString();
const hmac2 = crypto.createHmac("sha256", secret);
const signedPayload2 = timestamp.concat(".", rawBodyString);
const signedPayload2UTF8 = new TextEncoder("utf-8").encode(signedPayload2);
hmac2.update(signedPayload2UTF8);
const generatedSignature2 = hmac2.digest("base64");
Both of these generated signatures are the same, but unfortunately they are sometimes (not always) still different from the signature I received in the request header.
I also tried a version with const rawBodyString = new TextEncoder("utf-8").encode(req.rawBody);
but this always generates a wrong signature.
I'm also wondering what the TextEncoder would really do, as it is my current understanding that JSON.stringify
would produce a UTF-8 encoded string (but maybe I'm mistaken here).
So in the end, I'm still at at a loss.
Also, you need to send a 200 response after receiving the webhook, otherwise the webhook will be retried indefinitely (with the same idempotency key)
Yes, the function that calls validateJaasSignature
will then also return a status 200 via res
.
@mphasize utf-8 might be the default so it might not be needed explicitely on new TextEncoder instantiation, but .encode returns an Uint8Array
, which is then passed as message on hmac update call, so it is not a converter to UTF-8 text.
I also tried a version with const rawBodyString = new TextEncoder("utf-8").encode(req.rawBody); but this always generates a wrong signature.
This is because req.rawBody is not a string, but a byte array I think. Also the encode needs to be done on the whole string which includes the timestamp.<payload_json_stringify>
During our testing we noticed that if a webhook is missed which results in re-sending it with the same idempotency key, the body slightly differs the second time but the signature from the header remains the same as the original, which causes the resulting signature to not correspond anymore with the computed signature on the webhook listening endpoint. We have a fix for that but it was not yet deployed. Could this be the cause for signatures not always matching in your case?
CC @lstirb8x8
@horymury As far as I can tell, the signature mismatch already happens the first time that I see the request in our logs, but I will keep an eye out for this. Since our webhook handler is deployed as a Cloud Function on Firebase, I'm wondering if this might have something to do with a cold-start scenario. Maybe your first request get's a timeout and then already re-tries before I see it. I will go through the logs to see if this might be an explanation.
The documentation ( https://developer.8x8.com/jaas/docs/webhooks-signatures ) describes how we can check the signature from a webhook call, but a real code example would help with some of the questions we might have after reading the documentation.
For example:
Why one or more signatures? Does another signature always mean another version/protocol or could the message body somehow be broken in several parts with separate signatures?
As the example value contains an equal sign (=) as part of the signature value, I'm wondering how to avoid removing that by accident with a simple String.split...
Since this seems to refer to string concatenation, I'm wondering which specific options to use with JSON.stringify on the request body. Could I break the signature validation by using the wrong stringify settings?
Ummm.. what is constant-time string comparison and how do I do that? 😅
Anyway, a bit of sample code would be really fantastic. Thank you.