Travis Toulson
Administrator
Administrator

HMAC Validation in ServiceNow Securing Webhook Integrations with CertificateEncryption.png

Check out other articles in the C3 Series

 

Most of the integrations I've done have been a nice, simple Basic Auth and forget it. The closest I usually get to cryptography is the red plastic decoder you use to unravel the mystery on the back of a cereal box. So, when the Replicate AI docs informed me that I would have to perform something called an HMAC validation by doing some secret handshake with a signature generated using SHA-256 in order to validate the REST messages containing generated avatar URLs to CreatorCon C3...

 

I genuinely considered just hoping no one would find the public unauthenticated Scripted REST API webhooks.

 

Since you all are a clever bunch that would make me regret that decision, however, in this article we will walk through exactly how I got HMAC validation working on the Scripted REST API using the CertificateEncryption API.

 

What is HMAC Validation?

 

Before we dive into the technical details, let's first address why you might encounter HMAC validation. As we discussed in some of our previous C3 articles, webhooks are a way for API providers and 3rd party applications to send messages back to your application, usually after some event has occurred. In our case, Replicate AI sends a REST message to C3 when an avatar has been generated by their AI model.

 

The question then, is what prevents a malicious user from sending the same "avatar created" message to C3? Realistically... nothing. So we have to validate that the avatar created message came from Replicate AI. Usually, this would be done through an API key which is exactly what we use to call Replicate AI. But there is the tricky part. Replicate AI is the API but they are calling our app... in fact, they are calling every app that uses their API.

 

On one hand, Replicate could store authentication credentials for every single app they are responding to and make sure to authenticate to each app in the proper way. Or it could just provide a way for their client apps to verify that the webhook message is from Replicate AI and hasn't been tampered with.

 

That's what HMAC validation does. It's like a little wax seal on the message they send you that lets you verify that it is from Replicate AI and that no one has altered the message.

 

How HMAC Validation Works

 

Let's hit a few key terms for this discussion before diving into the process:

 

  • API: The 3rd party API that calls your webhook
  • App: Your application containing the webhook endpoint
  • Secret: The shared secret key that the API and App both have and keep secret from everyone else. The API will usually provide this secret key, typically some string value.
  • Message: The payload being sent to the webhook endpoint that requires validation
  • Signature: A string value generated by both the API and the App using the secret and a process specified by the API.

 

With the terms out of the way, the process of HMAC validation is actually quite simple:

 

  1. The API generates a signature using the secret and the process that they specify
  2. The API sends the signature and the message to the app's webhook endpoint
  3. The app generates a signature using the secret and the process the API specifies
  4. The app compares the API signature to the signature the app generated:
    1. If the signatures match, the message is validated as coming from the API and unaltered
    2. If the signatures do NOT match either:
      1. The message should not be trusted as coming from the API
      2. The message may have been altered via a Man in the Middle attack
      3. I messed up the signature generating code in the app

 

Replicate AI's HMAC Implementation

 

One thing to note is that different API's may dictate different processes to generate a signature. The only way to get a valid signature that matches the one sent by the API is to follow their process TO THE LETTER. More on that later.

 

As an example, Replicate AI used the following process:

 

Webhook Structure

 

Replicate AI includes three critical HTTP headers with each webhook:

 

webhook-id: msg_1234567890abcdef
webhook-timestamp: 1640995200
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo=

 

  • webhook-id: Unique identifier for this specific webhook delivery
  • webhook-timestamp: Unix timestamp indicating when the webhook was sent
  • webhook-signature: Space-delimited list of signatures with version prefixes

 

Generating a signature

 

To generate a signature, Replicate AI first creates the signed content which is done as follows:

 

const signedContent = `${webhook_id}.${webhook_timestamp}.${body}`;

 

They also provide a secret that looks something like whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD. The odd part is that the actual secret is everything excluding the whsec_ at the front which was a bit confusing. To generate the signature, Replicate AI uses the secret, the signed content, and the SHA-256 algorithm.

 

ServiceNow HMAC Validation Implementation

 

With those details, on the ServiceNow side we had to:

 

  1. Extract the HTTP headers
  2. Get the message content
  3. Build the signed content
  4. Generate the signature

 

It made sense to wrap some of the Replicate API specifics in a Script Include, so here is that Script Include from our app (minus some of the unrelated functions):

 

var ReplicateAI = Class.create();
ReplicateAI.prototype = {
    initialize: function () {
        this.token = '';
    },

    getWebhookSigningSecret: function () {
        return gs.getProperty('x_snc_cctcg_photo.replicate_ai_secret_key');
    },

    updateWebhookSigningSecret: function () {
        this.getAPIToken();
        const request = new sn_ws.RESTMessageV2();
        request.setEndpoint('https://api.replicate.com/v1/webhooks/default/secret');
        request.setHttpMethod('GET');
        request.setRequestHeader('Authorization', `Bearer ${this.token}`);
        request.setRequestHeader('Content-Type', "application/json");
        const resp = JSON.parse(request.execute().getBody());
        gs.setProperty('x_snc_cctcg_photo.replicate_ai_secret_key', resp.key);
    },

    isWebhookValid: function (request, body) {
        const secret = this.getWebhookSigningSecret().split('_')[1]; // Remove whsec_ prefix
        const signatures = request.getHeader('webhook-signature').split(' ');
        const webhookId = request.getHeader('webhook-id'); 
        const timestamp = request.getHeader('webhook-timestamp');

        return signatures.some((signature) => {
            let signatureBase64 = signature.split(',')[1]; // Remove version prefix (v1,)
            const signedContent = `${webhookId}.${timestamp}.${body}`;

            const mac = new CertificateEncryption();
            const generatedSignature = mac.generateMac(secret, 'HmacSHA256', signedContent);
            
            return signatureBase64 == generatedSignature;
        });
    },

    getAPIToken: function () {
        if (this.token) {
            return;
        }
        const credentialAlias = 'your_credential_alias_sys_id';
        const provider = new sn_cc.StandardCredentialsProvider();
        const cred = provider.getCredentialByAliasID(credentialAlias);
        this.token = cred.getAttribute('api_key');
    },

    type: 'ReplicateAI'
};

 

The isWebhookValid function (Line 22 above) is the entry point for the process and is executed from within a Scripted REST API (Line 8 Below):

 

(function process(request, response) {
    const log = new Log();
    const replicate = new ReplicateAI();
    const resp = request.body.data;
    
    try {
        // Critical: Use JSON.stringify to ensure exact body representation
        if (!replicate.isWebhookValid(request, JSON.stringify(resp))) {
            log.insert({
                type: 'Error',
                message: 'Unauthorized webhook attempt',
                payload: resp,
            });
            response.setStatus(401);
            return;
        }

        // Process validated webhook...
        if (resp && resp.output && resp.output.length) {
            // Handle successful AI processing
            resp.output.forEach((generatedImage) => {
                // Your business logic here
            });
        } else if (resp && resp.error) {
            // Handle AI processing errors
            log.insert({
                type: 'Error',
                message: 'AI processing failed',
                payload: resp,
            });
        }

    } catch (err) {
        log.insert({
            type: 'Error',
            message: 'Webhook processing error',
            payload: err.toString(),
        });
        response.setStatus(500);
    }
})(request, response);

 

These two scripts form the backbone of the HMAC Validation in our C3 app and you'll note that it's just executing the process we described above.

 

Mistakes and Why HMAC Debugging Sucks

 

If I'm being honest, the word "sucks" doesn't do the frustration of HMAC Validation justice. The process looks so easy. The problem is that when your signature doesn't match the API signature, you get zero helpful information. No partial matches. No "oh, you're so close". No "Error on Line 3". Just a Boolean value and some nonsense random string that is completely different from the other nonsense random string and also completely different from every other nonsense random string the function throws at you.

 

So, let's take a look at all the mistakes I made along the way.

 

Mistake #1: Algorithm Name Confusion

 

// This is what I tried first (because that's what the Replicate docs said)
mac.generateMac(secret, 'SHA-256', signedContent);

// But ServiceNow wants this format
mac.generateMac(secret, 'HmacSHA256', signedContent);

 

Sadly, there is no enum in ServiceNow telling you what algorithm names will work and what won't. It also seems that different functions on the CertificateEncryption API may use different names? This was quite confusing for me and truth be told, I'm not sure if SHA-256 would actually work. But HmacSHA25 was listed in the example for this particular function and it is what eventually worked for me. But watch out for those algorithms.

 

Mistake #2: The Secret Key Prefix Issue

 

// I forgot to strip this for way too long
const secret = 'whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD';

// You need to remove the whsec_ prefix
const secret = 'C2FVsBQIhrscChlQIMV+b5sSYspob7oD';

 

For a while the signature kept failing and I could not figure out why. I felt like I had everything else dialed in but one thing I overlooked in the Replicate AI process was that the whsec_ was supposed to be stripped off to obtain the secret. These minute details are really important when generating a signature. Those six letters of difference in the secret lead to dramatically different signatures with nothing in the resulting signature pointing to the reason why.

 

Mistake #3: Body Content Handling

 

// Non-Strings will get silently converted to strings like [Object object]
const body = request.body.data; 

// You need the exact string representation
const body = JSON.stringify(request.body.data);

 

Once again, the inputs into the signature generation are really important. JavaScript will gladly convert just about anything to a string in the most unhelpful ways.

 

Mistake #4: Base64 Encoding Confusion

 

My key was already base64 encoded from Replicate, but I was following ServiceNow examples that showed encoding the key. So, I was base64 encoding an already base64 encoded string. And since you can't triple stamp a double stamp - I got the wrong result.

 

Helpful Advice

 

As you can see, HMAC signatures are very sensitive to changes in input. So your process has to exactly match the API's process or you won't get a matching signature. Debugging was a huge pain but I did take some lessons away from it for the next time.

 

Helpful #1: Log Everything

 

Whether you debug by logging or using the sensible Script Debugger, you need to have a really good sense of your inputs and outputs. Since you don't have a ton of visibility into how the inputs turn into outputs, the best thing you can do is make sure your inputs match the desired inputs and figure out where they differ.

 

Helpful #2: Test with known values first

 

I would also advise that you record these inputs and outputs since they will help you create a table of test data. Since you are likely to be dealing with long running processes, it can be helpful to decouple the signature validation from the REST processes for testing. Having known inputs and outputs will make it much easier to run these tests. Even better if the API provides you an example set of data that validates.

 

Helpful #3: Work inside out

 

Usually, when we write scripts we start at the top and work our way down through the process. Because of the input sensitivity and lack of insight into unexpected results, I recommend starting with the generateMac function and working your way out. Test that function with known values, make sure you have the right algorithm and the right inputs in the right places. Then make the inputs more generic one at a time. This will help you maintain isolation on which values are causing the error.

Helpful #4: Write out the API's validation process

 

It helps to have a simplified checklist of all the steps and requirements. Replicate AI's documentation on HMAC validation is rather robust. But when I was debugging the code, I needed a quick checklist just to say "hey, did I meet this requirement in the process".  Checking these off made it easier for me to spot what I missed:

 

  • ✓ Concatenate webhook-id, timestamp, and body with periods
  • ✓ Use the raw body exactly as received
  • ✓ Strip the "whsec_" prefix from the secret
  • ✓ Remove the version prefix from signatures ("v1,")
  • ✓ Use SHA-256

 

Helpful #5: Don't be afraid to ask AI to help

 

I thought about this one after the fact, but it occurred to me later that I could probably have given AI a link to the Replicate AI docs and asked it to help me troubleshoot my script when I was having issues. Logic issues usually aren't detected by traditional code tooling, but AI adds a whole new set of capabilities for handling these sorts of logic issues.