Stream webhook digest mismatch

Hello! I’m trying to use the Stream webhook to receive updates when a uploaded video is ready to stream. I’ve implemented everything following the instructions on https://developers.cloudflare.com/stream/uploading-videos/using-webhooks#verify-webhook-authenticity to verify the webhook authenticity. I’ve tested it uploading videos through the quick upload on the stream dashboard and it worked flawlessly, but when I upload via API, the digest just doesn’t match in the webhook.

This is my current implementation:

const middleware = (req, res, next) => {
  const signatureHeader = req.headers['webhook-signature'];

  const signatureSplitted = new Map();
  signatureHeader.split(',').forEach((item) => signatureSplitted.set(...item.split('=')));

  const signatureSourceString = `${signatureSplitted.get('time')}.${JSON.stringify(req.body)}`;
  const hash = crypto.createHmac('sha256', config.get('cloudflare.streamWebhookSecret')).update(signatureSourceString);

  const digest = hash.digest('hex');
  if (digest !== signatureSplitted.get('sig1')) {
    throw new ExecutionError('INVALID_SIGNATURE_HEADER', 'This signature is invalid', 401);
  }
  next();
};

Uploading through here:


I receive the request on the webhook and the digest match with the received signature

But when I try to upload via API, using the cURL generated on the dashboard or on my application:


The signature and the generated digest are completely different

Is there anything that I’m missing? I’m trying to make it work for some hours and I really can’t see what’s wrong with the algorithm

After almost a month without any solution we decided to go live without this validation. We’ve tried to reimplement the algorithm using the provided samples in the docs but apparently there is something missing in the documentation itself or in our configuration. It’ll be less secure but it’s the way to go right now.

Hi there, I just ran some tests to try and reproduce your issue of the signature not matching only when you upload via URL. I was not able to reproduce the issue and the signatures matched regardless of how I uploaded the video.

I am including the steps of my test along with the sample code I used in case it helps:

First, you can that the video in my test was uploaded using a URL:

Next, you can see the signature returned by the webhook:

Finally, you can see the signature returned by my test worker script below:

Here is a Cloudflare Worker script I used to run my manual test:

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const signature = await stream_webhook_verify(YOUR_KEY, YOUR_PAYLOAD)
  return new Response("Signature: " + signature)
}


async function stream_webhook_verify(key,response_body) {

const getUtf8Bytes = str =>
  new Uint8Array(
    [...unescape(encodeURIComponent(str))].map(c => c.charCodeAt(0))
  );

const keyBytes = getUtf8Bytes(key);
const messageBytes = getUtf8Bytes(response_body);

const cryptoKey = await crypto.subtle.importKey(
  'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' },
  true, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, messageBytes);
return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join('');
}
2 Likes

Thanks for the reply! I’ve tried replacing my implementation by yours but still getting a different signature. My code is looking almost identical to the one you provided, but it’s a ExpressJS middleware instead of a Cloudflare Worker.

const getUtf8Bytes = str =>
  new Uint8Array(
    [...unescape(encodeURIComponent(str))].map(c => c.charCodeAt(0))
  );

const middleware = async (req, res, next) => {
  const signatureHeader = req.headers['webhook-signature'];

  const signatureSplitted = new Map();
  signatureHeader.split(',').forEach((item) => signatureSplitted.set(...item.split('=')));

  const keyBytes = getUtf8Bytes(config.get('cloudflare.streamWebhookSecret'));
  const messageBytes = getUtf8Bytes(JSON.stringify(req.body));

  const cryptoKey = await crypto.subtle.importKey(
    'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' },
    true, ['sign']
  );

  const sig = await crypto.subtle.sign('HMAC', cryptoKey, messageBytes);

  const digest = [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join('');

  if (digest !== signatureSplitted.get('sig1')) {
    throw new ExecutionError('INVALID_SIGNATURE_HEADER', 'This signature is invalid', 401);
  }
  next();
};

Just to make sure, the secret I have to use is the one returned on the endpoint /stream/webhook right?

As you can see, the results are always different:

This is the signature from the webhook

And this is the generated signature from the script

The secret I’m using is the same returned from the create webhook endpoint (I’ve already deleted this one)

image

As you can see, the code is basically the same you used in the worker, I’ve just added some error handling on top of it.

Are you prepending the time value and the “.” to the body to generate the signature source? If messageBytes is your signature source variable, then it seems like you might not be prepending the time value and the “.” as detailed here: https://developers.cloudflare.com/stream/uploading-videos/using-webhooks#step-2-create-signature-source-string

That’s right, I’ve forgot to prepend the time value and the “.” to the body. But the signatures still doesn’t match.

Now the messageBytes variable is:
const messageBytes = getUtf8Bytes(signatureSplitted.get('time') + "." + JSON.stringify(req.body));

I’ll try to create a Cloudflare worker with this exact same code to test if the problem may be something with my environment

I’ve created a Cloudflare Worker to test if the problem was on my local environment but apparently it isn’t. I’ve also generated a new webhook secret. The code of my worker is the following:

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const requestBody = await request.json();
  const signatureHeader = new Map(request.headers).get('webhook-signature');
  const signatureSplitted = new Map();
  signatureHeader.split(',').forEach((item) => signatureSplitted.set(...item.split('=')));
  const signatureSourceString = signatureSplitted.get('time') + '.' + JSON.stringify(requestBody);
  const signature = await stream_webhook_verify('df8efc7c28d385ed17c3d1309f7227ce1a6769c5', signatureSourceString)
  return new Response("Signature: " + signature)
}


async function stream_webhook_verify(key,response_body) {

const getUtf8Bytes = str =>
  new Uint8Array(
    [...unescape(encodeURIComponent(str))].map(c => c.charCodeAt(0))
  );

const keyBytes = getUtf8Bytes(key);
const messageBytes = getUtf8Bytes(response_body);

const cryptoKey = await crypto.subtle.importKey(
  'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' },
  true, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, messageBytes);
return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join('');
}

It is returning the signature

But it doesn’t match the webhook request signature

Can you try getting the raw response body (response.text()) instead of going from a JSON object to the string using JSON.stringify()? JSON.stringify might not return the exact bytes that were returned in the response and a single letter or space being off would cause a mismatch.

For my tests, I used a tool like https://webhook.site/ to manually form the signature source string, verifying it matches and then automating it using code. I would recommend trying something like that to help you debug.

2 Likes

It worked! The problem was the JSON.stringify() from the beginning. I tested it in the worker and also in the Node API and now it matches perfectly.
I think this is not clear in the docs, maybe it’s worth adding a note about this.
Thank you!

1 Like

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.