Signature Verification

How to handle signed requests from Web Hooks.

Because your webhook URL needs to be public to accept requests from Tidy, it is at risk of receiving fraudulent data from a malicious actor.

To counter this, each webhook request from Tidy includes a Tidy-Signature header that you can use alongside your webhook's unique signing key to verify that the contents of the request are unmodified and authentic.

A Tidy-Webhook-ID header is also included, which uniquely identifies the specific webhook if you have multiple sending to the same URL, this code is present on the list of webhooks page. You can use this header to decide which signing key to use to verify the message, but if verification succeeds, you should also check that the Tidy-Webhook-ID matches the webhook_id attribute in the message body.

The contents of the header currently look like this: t=unixtime,v1=hexsignature.

Make sure you parse out the t and v1 elements using a method that is tolerant for different key orderings and new uniquely-keyed elements to also be included, to ensure that future additions to the header do not break your code. i.e. split them by the , character, then the = character instead of relying on any fixed-length parsing or brittle order-dependent regexes.

As an example, to verify a signature with a five minute grace period would look like so:

require 'base64'
require 'openssl'
require 'json'

tolerance = 300 # 5 minutes
recieved_via_http_method = 'POST'
webhook_id_on_record = 'ff434f3g4t4y2'


payload = '{"message":"my webhook message"}'
header = 't=1677726570,v1=d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
secret = Base64.strict_decode64(
  'eIEEPEueMuEIz9rzNAL+hbJY6+KmbKkfowaYxcCO7ikWyysBXEnq1YBVF9AzIKWjvCzFVTQ33wWW3HeTZKoONA==')


# ['t=1677726570', 'v1=d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d']
signature_elements = header.split(',')


# [['t', '1677726570'], ['v1', 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d']]
element_pairs = signature_elements.map { |e| e.split('=') } 


# {'t'=>'1677726570', 'v1'=>'d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'}
elements = element_pairs.to_h


timestamp = elements['t'].to_i # 1677726570


# 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
signature = elements['v1'] 


# '1677726570.{"message":"my webhook message"}'
timestamped_payload = "#{timestamp}.#{payload}"


# 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
expected_sig = OpenSSL::HMAC.hexdigest(
   OpenSSL::Digest.new('sha256'), secret, timestamped_payload)


if signature != expected_sig # false
  raise 'INVALID SIGNATURE'
end


if timestamp < Time.now.to_i - tolerance # false
  raise 'MESSAGE IS TOO OLD'
end


data = JSON.parse(payload) # decode the payload now its verified


if data['webhook_id'] != webhook_id_on_record 
  || data['http_method'] != recieved_via_http_method # false
    raise 'MESSAGE CONTENTS MISMATCH'
end
import { createHmac } from 'crypto'

// based on https://github.com/stripe/stripe-node/blob/v12.16.0/src/Webhooks.ts
// see LICENSE for more details https://github.com/stripe/stripe-node/blob/v12.16.0/LICENSE

const verifySignature = (
  tidySignatureHeader: string,
  signingKeyB64: string,
  body: string,
  registeredWebhookId: string,
  httpMethod: string
) => {
  const signingKey = Buffer.from(signingKeyB64, 'base64')
  const details = parseHeader(tidySignatureHeader, 'v1')
  const tolerance = 300

  if (!details || details.timestamp === -1) {
    throw new Error('Unable to extract timestamp and signatures from header')
  }

  if (!details.signatures.length) {
    throw new Error('No signatures found with expected scheme')
  }

  const timestamp = details.timestamp
  const signature = details.signatures[0]

  const timestampedPayload = `${timestamp}.${body}`

  const expectedSignature = createHmac('sha256', signingKey)
    .update(timestampedPayload, 'utf8')
    .digest('hex')

  if (signature !== expectedSignature) {
    throw new Error('Signature mismatch')
  }

  const timestampAge = Math.floor(Date.now() / 1000) - timestamp

  if (tolerance > 0 && timestampAge > tolerance) {
    throw new Error('Timestamp outside the tolerance zone')
  }

  const data = JSON.parse(body)

  if (data.webhook_id !== registeredWebhookId) {
    throw new Error(
      `There has been a webhook ID mismatch, expected ${registeredWebhookId} got ${data.webhook_id}`
    )
  }

  if (data.http_method !== httpMethod) {
    throw new Error(
      `There has been a HTTP method mismatch, expected ${httpMethod} got ${data.http_method}`
    )
  }

  return data
}

const parseHeader = (header: string | null | undefined, scheme: string) => {
  if (typeof header !== 'string') {
    return null
  }

  return header.split(',').reduce(
    (accum, item) => {
      const kv = item.split('=')

      if (kv[0] === 't') {
        accum.timestamp = parseInt(kv[1], 10)
      }

      if (kv[0] === scheme) {
        accum.signatures.push(kv[1])
      }

      return accum
    },
    {
      timestamp: -1,
      signatures: [] as string[],
    }
  )
}