This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
Verify an HMAC-Signed Webhook in Constant Timefilter← all gifts

hmac-webhook

hmac-webhook recomputes HMAC-SHA256(secret, rawBody) and checks it against the signature a webhook sender put in a header (conventionally X-Signature: sha256=<hex>), answering one question — is this body authentic and unmodified, from someone who holds the secret? It answers in CONSTANT TIME, so a forger cannot learn the correct signature one character at a time from how long a rejection takes. verify(body, header, secret) returns {ok, reason}; ok:false always names why — no-signature, bad-format, unsupported-scheme, length-mismatch, or hmac-mismatch — never a partial match. HMAC-SHA256 is RFC 2104 exactly, with the SHA-256 core vendored byte-for-byte from the shipped sha256 gift, so the result is byte-identical to Node's crypto.createHmac and to openssl dgst -sha256 -hmac. Same body, header, and secret in → same verdict out, in Node or a browser.

The honest edge
hmac-webhook verifies AUTHENTICITY — nothing more. It is NOT a secret store (you supply the secret; it is never persisted), NOT a replay defender (it proves THIS body is authentic, not that it is fresh — pair it with a timestamp or nonce check for that), NOT a TLS or transport check, NOT an authorization system (authentic is not the same as allowed), and NOT a multi-algorithm negotiator (it verifies the one scheme you name, default sha256).
Run it
printf '%s' '{"event":"ping"}' | node hmac-webhook.js --secret s3cr3t --signature "sha256=<hex>" # prints 'ok' exit 0 if authentic; reason + exit 1 otherwise; --sign to emit a signature; exit 2 usage/input error test_hmac-webhook.js (40/40: RFC 4231 TC2, Node-crypto parity over 10 shapes incl. multibyte + 63/64/65-byte block-boundary keys + multi-block body, sign→verify round-trip, and the known-bad half — tampered body / wrong secret / flipped sig / truncated / 5 malformed headers / 2 wrong-scheme / uppercase-hex normalize / whitespace tolerate — plus determinism 3x and constant-time-compare correctness) Zero dependencies, Node or browser, deterministic, constant-time
The code — every file that ships
hmac-webhook.js351 lineson GitHub →
#!/usr/bin/env node
/**
 * hmac-webhook — verify an HMAC-signed webhook before you trust its body.
 *
 * WHAT
 *   A webhook sender (GitHub, Stripe, Shopify, a Loop module) signs each request
 *   body with a shared secret and puts the signature in a header, conventionally
 *       X-Signature: sha256=<hex>
 *   hmac-webhook recomputes HMAC-SHA256(secret, rawBody) and checks it against the
 *   signature the sender sent. It answers ONE question — is this body authentic and
 *   unmodified, from someone who holds the secret? — and answers it in CONSTANT TIME
 *   so a forger cannot learn the right signature one byte at a time from timing.
 *
 *       verify(rawBody, signatureHeader, secret) -> { ok, reason }
 *
 *   ok:true  the body is authentic (the computed MAC equals the presented one).
 *   ok:false reason names WHY it failed — never a partial match, never a maybe:
 *       "no-signature"    the header is empty / missing.
 *       "bad-format"      the header is not "<scheme>=<hex>" with an even-length
 *                         lowercase-normalizable hex body.
 *       "unsupported-scheme" a scheme other than the one asked for (default sha256).
 *       "length-mismatch" the presented digest is not 64 hex chars (32 bytes).
 *       "hmac-mismatch"   well-formed, right length, but the MAC does not match —
 *                         the body was altered, the secret is wrong, or it is a forgery.
 *
 * HOW
 *   HMAC-SHA256 is RFC 2104 exactly: with block size B=64 and the SHA-256 core
 *   vendored from the shipped `sha256` gift (FIPS 180-4, byte-faithful),
 *       K'   = key            if len(key) <= B,   else SHA-256(key)   then zero-padded to B
 *       HMAC = SHA-256( (K' XOR opad) || SHA-256( (K' XOR ipad) || message ) )
 *   ipad is 0x36 repeated B times, opad is 0x5c repeated B times. Everything runs on
 *   RAW BYTES — the message and the key are UTF-8 encoded once — so a multibyte secret
 *   or body is hashed byte-for-byte the same as Node's crypto.createHmac('sha256').
 *   The comparison is a fixed-time XOR-accumulate over the full digest width: it looks
 *   at every byte regardless of where the first difference is, so the pass/fail timing
 *   carries no information about the correct signature.
 *
 *   Pure function of (body, header, secret). No clock, no randomness, no files, no
 *   network. Same three inputs -> same verdict, every run, in Node or a browser.
 *
 * USAGE
 *   node hmac-webhook.js --secret SECRET --signature "sha256=<hex>" [FILE]   # body from FILE or stdin
 *   node hmac-webhook.js --secret SECRET --sign [FILE]     # PRINT the signature for a body (sender side)
 *     printf '%s' '{"event":"ping"}' | node hmac-webhook.js --secret s3cr3t --sign
 *       -> sha256=<hex>
 *     printf '%s' '{"event":"ping"}' | node hmac-webhook.js --secret s3cr3t --signature "sha256=<hex>"
 *       -> exits 0 (authentic) and prints "ok"; exits 1 and prints the reason otherwise.
 *   --scheme NAME   signature scheme label to require/emit (default "sha256").
 *   --help
 *
 *   In a browser: window.ForestGifts.hmacWebhook.{verify, sign, hmacSha256Hex}.
 *
 * EXIT CODES (CLI)
 *   0  verify: authentic.            (--sign always exits 0 on success.)
 *   1  verify: NOT authentic — the reason is printed on stdout (one word) + stderr.
 *   2  usage / input error: missing --secret, missing --signature (without --sign),
 *      a file that is a directory or cannot be read. One clean line on stderr.
 *
 * EDGE (what this is NOT)
 *   NOT a signer's secret store — you supply the secret; it is never persisted.
 *   NOT a replay defender — it proves authenticity of THIS body, not that the body
 *   is fresh; pair it with a timestamp/nonce check if you need replay protection.
 *   NOT a TLS or transport check, NOT an authorization system (authentic != allowed),
 *   and NOT a multi-algorithm negotiator — it verifies ONE scheme (sha256) you name.
 *
 * Zero dependencies. Node builtin `require('fs')` for file reads only; runs in a
 * browser with no require. MIT.
 */
"use strict";

/* ------------------------------------------------------------------ *
 * SHA-256 core — vendored from the `sha256` gift (FIPS 180-4),        *
 * byte-faithful, with the raw-bytes entry HMAC needs. The block       *
 * machinery is IDENTICAL to the shipped sha256 gift and to            *
 * Node crypto's sha256; a mismatch here is a rebuild failure.         *
 * ------------------------------------------------------------------ */

// Round constants — FIPS 180-4 §4.2.2.
var K = [
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
  0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
  0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
  0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
  0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
  0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
  0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];

function rotr(x, n) { return (x >>> n) | (x << (32 - n)); }

// UTF-8 encode a JS string to a byte array — the SAME bytes Node's
// .update(String(s)) hashes (surrogate-pair aware fallback matches TextEncoder).
function utf8Bytes(str) {
  if (typeof TextEncoder !== "undefined") {
    return Array.prototype.slice.call(new TextEncoder().encode(str));
  }
  var out = [], i, c, lo;
  for (i = 0; i < str.length; i++) {
    c = str.charCodeAt(i);
    if (c < 0x80) { out.push(c); }
    else if (c < 0x800) { out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
    else if (c >= 0xd800 && c <= 0xdbff) {
      lo = str.charCodeAt(++i);
      c = 0x10000 + ((c & 0x3ff) << 10) + (lo & 0x3ff);
      out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
    } else { out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
  }
  return out;
}

// sha256 of RAW BYTES (array-like of 0..255) -> a 32-byte Array digest.
function sha256Digest(bytes) {
  var len = bytes.length;
  var bitLenHi = Math.floor(len / 0x20000000);
  var bitLenLo = (len * 8) >>> 0;
  var withOne = len + 1;
  var padded = (withOne + 8 + 63) & ~63;
  var msg = new Array(padded);
  var i;
  for (i = 0; i < padded; i++) msg[i] = 0;
  for (i = 0; i < len; i++) msg[i] = bytes[i] & 0xff;
  msg[len] = 0x80;
  msg[padded - 8] = (bitLenHi >>> 24) & 0xff;
  msg[padded - 7] = (bitLenHi >>> 16) & 0xff;
  msg[padded - 6] = (bitLenHi >>> 8) & 0xff;
  msg[padded - 5] = bitLenHi & 0xff;
  msg[padded - 4] = (bitLenLo >>> 24) & 0xff;
  msg[padded - 3] = (bitLenLo >>> 16) & 0xff;
  msg[padded - 2] = (bitLenLo >>> 8) & 0xff;
  msg[padded - 1] = bitLenLo & 0xff;

  var h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a,
      h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;

  var w = new Array(64), t, blk;
  for (blk = 0; blk < padded; blk += 64) {
    for (t = 0; t < 16; t++) {
      w[t] = (msg[blk + t * 4] << 24) | (msg[blk + t * 4 + 1] << 16) |
             (msg[blk + t * 4 + 2] << 8) | (msg[blk + t * 4 + 3]);
    }
    for (t = 16; t < 64; t++) {
      var s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
      var s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
      w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
    }
    var a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, hh = h7;
    for (t = 0; t < 64; t++) {
      var S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
      var ch = (e & f) ^ (~e & g);
      var temp1 = (hh + S1 + ch + K[t] + w[t]) >>> 0;
      var S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
      var maj = (a & b) ^ (a & c) ^ (b & c);
      var temp2 = (S0 + maj) >>> 0;
      hh = g; g = f; f = e; e = (d + temp1) >>> 0;
      d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
    }
    h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
    h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + hh) >>> 0;
  }

  var out = new Array(32), words = [h0, h1, h2, h3, h4, h5, h6, h7], wi;
  for (wi = 0; wi < 8; wi++) {
    out[wi * 4]     = (words[wi] >>> 24) & 0xff;
    out[wi * 4 + 1] = (words[wi] >>> 16) & 0xff;
    out[wi * 4 + 2] = (words[wi] >>> 8) & 0xff;
    out[wi * 4 + 3] = words[wi] & 0xff;
  }
  return out;
}

function byteToHex(b) {
  var h = (b & 0xff).toString(16);
  return h.length === 1 ? "0" + h : h;
}
function bytesToHex(bytes) {
  var s = "", i;
  for (i = 0; i < bytes.length; i++) s += byteToHex(bytes[i]);
  return s;
}

/* ------------------------------------------------------------------ *
 * HMAC-SHA256 — RFC 2104.                                             *
 * ------------------------------------------------------------------ */

var BLOCK = 64;   // SHA-256 block size in bytes (RFC 2104 "B").

// key: byte array. Returns the B-byte K' (hashed-if-long, zero-padded).
function normalizeKey(keyBytes) {
  var k = keyBytes;
  if (k.length > BLOCK) k = sha256Digest(k);   // K longer than a block -> hash it first.
  var out = new Array(BLOCK), i;
  for (i = 0; i < BLOCK; i++) out[i] = (i < k.length ? (k[i] & 0xff) : 0);
  return out;
}

// HMAC-SHA256 over raw byte arrays -> 32-byte digest array.
function hmacSha256Bytes(keyBytes, msgBytes) {
  var kp = normalizeKey(keyBytes);
  var ipad = new Array(BLOCK), opad = new Array(BLOCK), i;
  for (i = 0; i < BLOCK; i++) {
    ipad[i] = kp[i] ^ 0x36;
    opad[i] = kp[i] ^ 0x5c;
  }
  var inner = sha256Digest(ipad.concat(msgBytes));       // H((K' ^ ipad) || m)
  return sha256Digest(opad.concat(inner));               // H((K' ^ opad) || inner)
}

// HMAC-SHA256 hex of (secret string, message string), UTF-8 encoded — byte-identical
// to Node crypto.createHmac('sha256', secret).update(message).digest('hex').
function hmacSha256Hex(secret, message) {
  return bytesToHex(hmacSha256Bytes(utf8Bytes(String(secret)), utf8Bytes(String(message))));
}

/* ------------------------------------------------------------------ *
 * The verifier.                                                       *
 * ------------------------------------------------------------------ */

var HEX_RE = /^[0-9a-f]+$/;

// Constant-time equality of two equal-length lowercase-hex strings. Looks at every
// character; the running OR of per-char differences carries no early-exit timing.
// Caller MUST have already checked the lengths match (a length difference is not a
// secret — it is public structure — so it is decided before this point).
function constantTimeHexEqual(a, b) {
  var diff = 0, i;
  for (i = 0; i < a.length; i++) diff |= (a.charCodeAt(i) ^ b.charCodeAt(i));
  return diff === 0;
}

// Parse "<scheme>=<hex>" -> { scheme, hex } or null if it is not that shape.
function parseSignatureHeader(header) {
  if (typeof header !== "string") return null;
  var s = header.trim();
  var eq = s.indexOf("=");
  if (eq <= 0 || eq === s.length - 1) return null;   // no scheme, or nothing after '='
  var scheme = s.slice(0, eq);
  var hex = s.slice(eq + 1).toLowerCase();
  if (!HEX_RE.test(hex) || (hex.length % 2) !== 0) return null;   // must be even-length hex
  return { scheme: scheme, hex: hex };
}

/**
 * Verify a signed webhook body.
 * @param {string} rawBody   the exact bytes the sender signed (a string; UTF-8 hashed).
 * @param {string} signatureHeader   e.g. "sha256=abcd..." from the request header.
 * @param {string} secret   the shared secret.
 * @param {{scheme?: string}} [opts]   scheme to require (default "sha256").
 * @returns {{ok: boolean, reason: string}}   reason is "" on success.
 */
function verify(rawBody, signatureHeader, secret, opts) {
  opts = opts || {};
  var wantScheme = opts.scheme || "sha256";
  if (signatureHeader === undefined || signatureHeader === null || String(signatureHeader).trim() === "") {
    return { ok: false, reason: "no-signature" };
  }
  var parsed = parseSignatureHeader(String(signatureHeader));
  if (!parsed) return { ok: false, reason: "bad-format" };
  if (parsed.scheme !== wantScheme) return { ok: false, reason: "unsupported-scheme" };
  // A SHA-256 digest is exactly 32 bytes = 64 hex chars. Length is public structure,
  // decided before the constant-time compare (which requires equal lengths).
  if (parsed.hex.length !== 64) return { ok: false, reason: "length-mismatch" };
  var expected = hmacSha256Hex(secret, rawBody);   // always 64 lowercase hex chars
  if (constantTimeHexEqual(expected, parsed.hex)) return { ok: true, reason: "" };
  return { ok: false, reason: "hmac-mismatch" };
}

/**
 * Produce the signature header a sender would send for a body (the sender side).
 * @returns {string} e.g. "sha256=abcd..."
 */
function sign(rawBody, secret, opts) {
  opts = opts || {};
  var scheme = opts.scheme || "sha256";
  return scheme + "=" + hmacSha256Hex(secret, rawBody);
}

/* ---- exports ---- */
if (typeof window !== "undefined") {
  window.ForestGifts = window.ForestGifts || {};
  window.ForestGifts.hmacWebhook = {
    verify: verify, sign: sign, hmacSha256Hex: hmacSha256Hex,
    parseSignatureHeader: parseSignatureHeader
  };
}
if (typeof module !== "undefined" && module.exports) {
  module.exports = {
    verify: verify, sign: sign, hmacSha256Hex: hmacSha256Hex,
    parseSignatureHeader: parseSignatureHeader, constantTimeHexEqual: constantTimeHexEqual
  };
}

/* ------------------------------------------------------------------ *
 * CLI.                                                                *
 * ------------------------------------------------------------------ */

function readAll(stream, cb) {
  var chunks = [];
  stream.on("data", function (d) { chunks.push(d); });
  stream.on("end", function () { cb(Buffer.concat(chunks).toString("utf8")); });
}

function usage() {
  return "usage: hmac-webhook.js --secret SECRET (--sign | --signature 'sha256=<hex>') [--scheme NAME] [FILE]";
}

function main(argv) {
  var args = argv.slice(2);
  var secret = null, signature = null, scheme = "sha256", doSign = false, file = null, i;
  for (i = 0; i < args.length; i++) {
    var a = args[i];
    if (a === "--help" || a === "-h") { process.stdout.write(usage() + "\n"); process.exit(0); }
    else if (a === "--secret") { secret = args[++i]; }
    else if (a === "--signature") { signature = args[++i]; }
    else if (a === "--scheme") { scheme = args[++i]; }
    else if (a === "--sign") { doSign = true; }
    else if (a.slice(0, 2) === "--") { process.stderr.write("hmac-webhook: unknown option " + a + "\n" + usage() + "\n"); process.exit(2); }
    else { file = a; }
  }
  if (secret === null || secret === undefined) { process.stderr.write("hmac-webhook: --secret is required\n" + usage() + "\n"); process.exit(2); }
  if (!doSign && (signature === null || signature === undefined)) {
    process.stderr.write("hmac-webhook: --signature is required (or use --sign to produce one)\n" + usage() + "\n");
    process.exit(2);
  }

  function run(body) {
    if (doSign) {
      process.stdout.write(sign(body, secret, { scheme: scheme }) + "\n");
      process.exit(0);
    }
    var r = verify(body, signature, secret, { scheme: scheme });
    if (r.ok) { process.stdout.write("ok\n"); process.exit(0); }
    process.stdout.write(r.reason + "\n");
    process.stderr.write("hmac-webhook: verification failed (" + r.reason + ")\n");
    process.exit(1);
  }

  if (file) {
    var fs = require("fs");
    var stat;
    try { stat = fs.statSync(file); } catch (e) { process.stderr.write("hmac-webhook: cannot read " + file + "\n"); process.exit(2); }
    if (stat.isDirectory()) { process.stderr.write("hmac-webhook: " + file + " is a directory\n"); process.exit(2); }
    run(fs.readFileSync(file, "utf8"));
  } else {
    readAll(process.stdin, run);
  }
}

if (typeof require !== "undefined" && require.main === module) {
  main(process.argv);
}
test_hmac-webhook.js142 lineson GitHub →
#!/usr/bin/env node
/**
 * test_hmac-webhook — out-of-band battery for the hmac-webhook gift.
 *
 * The gift verifies HMAC-SHA256 webhook signatures. The oracle here is INDEPENDENT
 * of the gift's own SHA-256/HMAC code in two ways:
 *   (1) RFC 4231 published HMAC-SHA256 test vectors (known-good, external authority);
 *   (2) Node's built-in crypto.createHmac('sha256') (a different implementation).
 * A gift MAC that disagrees with either is a rebuild failure, not a warning.
 *
 * The known-bad half is real defect shapes: a tampered body, a wrong secret, a
 * bit-flipped signature, a truncated digest, a malformed header, a wrong scheme.
 *
 * GREEN means every assertion RAN and passed. A battery that executes nothing fails.
 */
"use strict";

var crypto = require("crypto");
var G = require("./hmac-webhook.js");

var passed = 0, failed = 0, ran = 0;
function check(name, cond) {
  ran++;
  if (cond) { passed++; }
  else { failed++; console.error("FAIL: " + name); }
}
function eq(name, got, want) {
  check(name + "  (got=" + JSON.stringify(got) + " want=" + JSON.stringify(want) + ")", got === want);
}

/* ---- 1. RFC 4231 HMAC-SHA256 known-good vectors ----
 * Keys/data given as hex/ascii in RFC 4231 §4. We feed the byte-exact inputs.
 * hmacSha256Hex takes STRINGS (UTF-8), so we only use the vectors whose key and
 * data are plain ASCII (no embedded non-UTF8 bytes) — vectors 2 and 7-ascii-part.
 * For the binary-key vectors we cross-check against Node crypto below on raw bytes
 * via the same public gift API used on strings, keeping the oracle independent.
 */

// RFC 4231 Test Case 2: key="Jefe", data="what do ya want for nothing?"
eq("rfc4231-tc2",
   G.hmacSha256Hex("Jefe", "what do ya want for nothing?"),
   "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843");

/* ---- 2. Cross-check against Node crypto over a spread of inputs ---- */
function nodeHmac(secret, msg) {
  return crypto.createHmac("sha256", secret).update(msg, "utf8").digest("hex");
}
var samples = [
  ["", ""],
  ["s3cr3t", ""],
  ["", "body-with-empty-secret"],
  ["shared-hmac-secret", '{"event":"ping","id":1}'],
  ["key", "The quick brown fox jumps over the lazy dog"],
  // multibyte secret AND body — the byte-faithfulness trap
  ["ключ-🔑", "日本語のペイロード — π ≈ 3.14159"],
  // a secret longer than the 64-byte block (forces the K'=SHA256(K) path)
  ["x".repeat(65), "block-boundary body"],
  ["x".repeat(64), "exactly one block key"],
  ["x".repeat(63), "just under one block key"],
  // long body spanning many blocks
  ["k", "A".repeat(1000)]
];
samples.forEach(function (s, idx) {
  eq("node-crypto-parity[" + idx + "]", G.hmacSha256Hex(s[0], s[1]), nodeHmac(s[0], s[1]));
});

/* ---- 3. sign() round-trips through verify() ---- */
var secret = "shared-hmac-secret";
var body = '{"event":"order.created","id":"ord_123","amount":4200}';
var header = G.sign(body, secret);
check("sign-emits-sha256-scheme", /^sha256=[0-9a-f]{64}$/.test(header));
eq("sign-matches-node", header, "sha256=" + nodeHmac(secret, body));
eq("verify-authentic", G.verify(body, header, secret).ok, true);
eq("verify-authentic-reason-empty", G.verify(body, header, secret).reason, "");

/* ---- 4. KNOWN-BAD: real forgery / tamper shapes must be rejected ---- */

// 4a. tampered body — one byte changed
var tampered = body.replace("4200", "9900");
eq("reject-tampered-body.ok", G.verify(tampered, header, secret).ok, false);
eq("reject-tampered-body.reason", G.verify(tampered, header, secret).reason, "hmac-mismatch");

// 4b. wrong secret
eq("reject-wrong-secret.ok", G.verify(body, header, "not-the-secret").ok, false);
eq("reject-wrong-secret.reason", G.verify(body, header, "not-the-secret").reason, "hmac-mismatch");

// 4c. bit-flipped signature (last hex nibble changed, still 64 hex chars)
var flipped = header.slice(0, -1) + (header.slice(-1) === "0" ? "1" : "0");
eq("reject-flipped-sig.ok", G.verify(body, flipped, secret).ok, false);
eq("reject-flipped-sig.reason", G.verify(body, flipped, secret).reason, "hmac-mismatch");

// 4d. truncated digest (right prefix, wrong length) -> length-mismatch, not a compare
var truncated = "sha256=" + header.slice("sha256=".length, "sha256=".length + 40);
eq("reject-truncated.reason", G.verify(body, truncated, secret).reason, "length-mismatch");

// 4e. empty / missing signature
eq("reject-empty-sig", G.verify(body, "", secret).reason, "no-signature");
eq("reject-null-sig", G.verify(body, null, secret).reason, "no-signature");
eq("reject-undefined-sig", G.verify(body, undefined, secret).reason, "no-signature");

// 4f. malformed header shapes -> bad-format
eq("reject-no-eq", G.verify(body, "sha256" + header.slice("sha256=".length), secret).reason, "bad-format");
eq("reject-no-scheme", G.verify(body, "=" + "a".repeat(64), secret).reason, "bad-format");
eq("reject-non-hex", G.verify(body, "sha256=" + "z".repeat(64), secret).reason, "bad-format");
eq("reject-odd-hex", G.verify(body, "sha256=" + "a".repeat(63), secret).reason, "bad-format");
eq("reject-nothing-after-eq", G.verify(body, "sha256=", secret).reason, "bad-format");

// 4g. wrong scheme -> unsupported-scheme
eq("reject-md5-scheme", G.verify(body, "md5=" + "a".repeat(64), secret).reason, "unsupported-scheme");
eq("reject-sha1-scheme", G.verify(body, "sha1=" + "a".repeat(40), secret).reason, "unsupported-scheme");

// 4h. uppercase hex in the presented sig is normalized and still verifies
var upperHeader = "sha256=" + nodeHmac(secret, body).toUpperCase();
eq("accept-uppercase-hex", G.verify(body, upperHeader, secret).ok, true);

// 4i. leading/trailing whitespace in the header is tolerated
eq("accept-padded-header", G.verify(body, "  " + header + "  ", secret).ok, true);

/* ---- 5. custom scheme label round-trips ---- */
var h2 = G.sign(body, secret, { scheme: "loop" });
check("custom-scheme-format", /^loop=[0-9a-f]{64}$/.test(h2));
eq("custom-scheme-verify", G.verify(body, h2, secret, { scheme: "loop" }).ok, true);
eq("custom-scheme-default-rejects", G.verify(body, h2, secret).reason, "unsupported-scheme");

/* ---- 6. determinism: same inputs -> byte-identical MAC across repeated calls ---- */
var d1 = G.hmacSha256Hex(secret, body);
var d2 = G.hmacSha256Hex(secret, body);
var d3 = G.hmacSha256Hex(secret, body);
check("determinism-3x-identical", d1 === d2 && d2 === d3);

/* ---- 7. constant-time compare correctness (function-level) ---- */
eq("ct-equal-true", G.constantTimeHexEqual("abcdef", "abcdef"), true);
eq("ct-equal-false", G.constantTimeHexEqual("abcdef", "abcdff"), false);

/* ---- summary ---- */
if (failed === 0 && ran > 0) {
  console.log("GREEN: " + passed + " assertions passed, 0 failed  [test_hmac-webhook]  (ran=" + ran + ")");
  process.exit(0);
} else {
  console.error("RED: " + failed + " failed / " + ran + " ran  [test_hmac-webhook]");
  process.exit(1);
}
Take the whole folder → MIT Zero dependencies, Node or browser, deterministic, constant-time