Search code examples
javascriptpythonwebhooksdigital-signaturehmac

Why are the validations of signatures different?


I need to validate a signature. The code examples for signature validation are only in Python. Here is the Python function:

import hmac
import hashlib
import json


def verify_signature(key, timestamp, provided_signature, payload):
  key_bytes = bytes.fromhex(key)
  payload_str = json.dumps(payload)
  data = timestamp + payload_str
  signature = hmac.new(key_bytes, data.encode('utf-8'),
                       hashlib.sha256).hexdigest()
  valid = hmac.compare_digest(provided_signature, signature)
  return valid

I need to translate some Python code into TypeScript.

Here is my best attempt:

export function verifyCloseSignature(
  request: Request,
  key: string,
  payload: any,
) {
  const headers = request.headers;

  const timestamp = headers.get('close-sig-timestamp');
  const providedSignature = headers.get('close-sig-hash');

  if (!timestamp) {
    throw new Error('[verifyCloseSignature] Required timestamp header missing');
  }

  if (!providedSignature) {
    throw new Error('[verifyCloseSignature] Required signature header missing');
  }

  const payloadString = JSON.stringify(payload);
  const hmac = crypto.createHmac('sha256', Buffer.from(key, 'hex'));
  hmac.update(timestamp + payloadString);
  const calculatedSignature = hmac.digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(providedSignature, 'hex'),
    Buffer.from(calculatedSignature, 'hex'),
  );
}

Why is this code not equivalent? Data that is validated by the Python code, fails the validation in JavaScript, when used like this:

const headers = new Headers();
headers.set('close-sig-hash', signature);
headers.set('close-sig-timestamp', timestamp.toString());
headers.set('Content-Type', 'application/json');
const request = new Request(faker.internet.url(), {
  method: 'POST',
  headers: headers,
  body: JSON.stringify(payload),
});

const actual = verifyCloseSignature(request, key, payload);
const expected = true;

expect(actual).toEqual(expected);

I expect the function to return true.


Solution

  • @SUTerliakov's SO link helped me to find a good solution. It is indeed caused by the extra white space.

    Here is the code that I ended up with:

    import { pipe, replace } from Ramda;
    
    export const toJSONWithSpaces = pipe(
      (object: unknown) => JSON.stringify(object, null, 1), // stringify with line-breaks and indents
      replace(/\n +/gm, ' '), // replace line breaks and following spaces with a single space
      replace(/:\s/g, ': '), // ensure a space after colon
      replace(/{\s/g, '{'), // remove space after opening brace
      replace(/\s}/g, '}'), // remove space before closing brace
      replace(/\[\s/g, '['), // remove space after opening bracket
      replace(/\s]/g, ']'), // remove space before closing bracket
      replace(/,\s/g, ', '), // ensure a space after comma
    );
    
    export function verifyCloseSignature(
      request: Request,
      key: string,
      payload: any,
    ) {
      const headers = request.headers;
    
      const timestamp = headers.get('close-sig-timestamp');
      const providedSignature = headers.get('close-sig-hash');
    
      if (!timestamp) {
        throw new Error('[verifyCloseSignature] Required timestamp header missing');
      }
    
      if (!providedSignature) {
        throw new Error('[verifyCloseSignature] Required signature header missing');
      }
    
      const hmac = crypto.createHmac('sha256', Buffer.from(key, 'hex'));
      const cleanedPayload = toJSONWithSpaces(payload);
      hmac.update(timestamp + cleanedPayload);
      const calculatedSignature = hmac.digest('hex');
    
      return crypto.timingSafeEqual(
        Buffer.from(providedSignature, 'hex'),
        Buffer.from(calculatedSignature, 'hex'),
      );
    }