Reference Source Test

src/webhooks/resources/verify_header.js

const crypto = require('crypto');

const DEFAULT_TOLERANCE = 300; // 5 minutes
const EXPECTED_SCHEME = 'v1';

/**
 * Secure compare, from https://github.com/freewil/scmp
 */
function secureCompare(_a, _b) {
  const a = Buffer.from(_a);
  const b = Buffer.from(_b);

  // return early here if buffer lengths are not equal since timingSafeEqual
  // will throw if buffer lengths are not equal
  if (a.length !== b.length) {
    return false;
  }

  // use crypto.timingSafeEqual if available (since Node.js v6.6.0),
  // otherwise use our own scmp-internal function.
  if (crypto.timingSafeEqual) {
    return crypto.timingSafeEqual(a, b);
  }

  const len = a.length;
  let result = 0;

  for (let i = 0; i < len; i += 1) {
    /* eslint-disable no-bitwise */
    result |= a[i] ^ b[i];
  }
  return result === 0;
}

class VerifyHeader {
  static parseHeader(header, scheme = EXPECTED_SCHEME) {
    if (typeof header !== 'string') {
      return null;
    }

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

        if (kv[0] === 't') {
          /* eslint-disable no-param-reassign, prefer-destructuring */
          accum.timestamp = kv[1];
        }

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

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

  static computeSignature(payload, secret) {
    return crypto
      .createHmac('sha256', secret)
      .update(payload, 'utf8')
      .digest('hex');
  }

  static verify(_payload, _header, secret, tolerance = DEFAULT_TOLERANCE) {
    const payload = Buffer.isBuffer(_payload)
      ? _payload.toString('utf8')
      : _payload;
    const header = Buffer.isBuffer(_header)
      ? _header.toString('utf8')
      : _header;

    const details = this.parseHeader(header);

    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 expectedSignature = this.computeSignature(
      `${details.timestamp}.${payload}`,
      secret
    );

    const signatureFound = !!details.signatures.filter(sig =>
      secureCompare(sig, expectedSignature)
    ).length;

    if (!signatureFound) {
      throw new Error(
        'No signatures found matching the expected signature for payload.'
      );
    }

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

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

    return true;
  }
}

module.exports = VerifyHeader;