Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions worker/src/signature.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
/**
* GitHub webhook signature verification (#227).
* GitHub webhook signature verification (#227, hardened in #229).
*
* Verifies GitHub's `X-Hub-Signature-256` header — an HMAC-SHA256 digest of the
* raw request body keyed with the shared webhook secret — using Web Crypto
* (`crypto.subtle`). Returns true only when the recomputed `sha256=<hex>` digest
* exactly matches the supplied signature string; any mismatch (tampered body,
* Validates GitHub's `X-Hub-Signature-256` header — an HMAC-SHA256 digest of the
* raw request body keyed with the shared webhook secret — using Web Crypto's
* `crypto.subtle.verify`, which recomputes the HMAC and compares it in CONSTANT
* TIME. The previous implementation compared the recomputed digest with the
* supplied signature via `===`, which early-exits on the first mismatched
* character and leaks the expected signature through response timing — a timing
* side-channel at the webhook trust boundary. The functional contract is
* unchanged: a correct signature returns true; any mismatch (tampered body,
* wrong secret, malformed signature) returns false. This is the webhook trust
* boundary: it proves both authentic GitHub origin and body integrity.
*/
Expand All @@ -13,17 +17,26 @@ export async function verifyGitHubSignature(
body: string,
signature: string,
): Promise<boolean> {
const prefix = "sha256=";
if (!signature.startsWith(prefix)) return false;
const hex = signature.slice(prefix.length);
// An HMAC-SHA256 digest is 32 bytes = exactly 64 hex chars. Reject any other
// shape before touching crypto so malformed input fails fast and uniformly.
if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;

const sigBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
sigBytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}

const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
["verify"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
const expected = "sha256=" + Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return expected === signature;
// verify() recomputes the HMAC and compares it in constant time.
return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(body));
}
64 changes: 38 additions & 26 deletions worker/test/signature.test.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,76 @@
/**
* Unit tests for worker/src/signature.ts (#227).
* Unit tests for worker/src/signature.ts (#227, constant-time switch in #229).
*
* verifyGitHubSignature is the webhook trust boundary: it must accept a body
* signed with the matching secret and reject every other case. The expected
* valid signature is generated INDEPENDENTLY via Node's node:crypto
* (createHmac → "sha256=" + hex digest), giving an oracle that does not call
* the function under test. crypto.subtle exists in Node 20+, so this runs as a
* pure tsx --test unit test (no Miniflare).
* signed with the matching secret and reject every other case. The primary
* oracle is GitHub's OWN published example vector from "Validating webhook
* deliveries" (secret / body / signature below), which cross-checks the impl
* against GitHub's real contract independently of any code under test. An
* independent node:crypto createHmac oracle is also kept for additional
* generated cases.
*
* #229 switched the internal compare from a non-constant-time `===` (which
* early-exits and leaks the expected signature through timing) to
* crypto.subtle.verify (constant-time internal compare). The timing property
* itself is not unit-observable; these tests pin the FUNCTIONAL contract that
* the constant-time implementation preserves. crypto.subtle exists in Node 20+,
* so this runs as a pure tsx --test unit test (no Miniflare).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";

import { verifyGitHubSignature } from "../src/signature.js";

const SECRET = "s3cr3t-webhook-key";
const BODY = JSON.stringify({ action: "opened", number: 42 });
// GitHub's published example vector ("Validating webhook deliveries" docs).
const SECRET = "It's a Secret to Everybody";
const BODY = "Hello, World!";
const VALID =
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";

/** Independent oracle: GitHub's X-Hub-Signature-256 = "sha256=" + HMAC-SHA256 hex. */
function sign(secret: string, body: string): string {
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
}

// ── Valid case ───────────────────────────────────────────────────────
// ── Valid case (GitHub's published example vector) ───────────────────

test("accepts a signature generated with the same secret and body", async () => {
const signature = sign(SECRET, BODY);
assert.equal(await verifyGitHubSignature(SECRET, BODY, signature), true);
test("accepts GitHub's published example vector", async () => {
assert.equal(await verifyGitHubSignature(SECRET, BODY, VALID), true);
});

// ── Tampered body ────────────────────────────────────────────────────

test("rejects when the body was tampered after signing", async () => {
// Sign body A, verify against body B.
const signature = sign(SECRET, BODY);
const tamperedBody = JSON.stringify({ action: "opened", number: 9999 });
assert.equal(await verifyGitHubSignature(SECRET, tamperedBody, signature), false);
// Verify a different body against the signature for the original body.
assert.equal(await verifyGitHubSignature(SECRET, "Hello, World?", VALID), false);
});

// ── Wrong secret ─────────────────────────────────────────────────────

test("rejects when verified with a different secret", async () => {
const signature = sign(SECRET, BODY);
assert.equal(await verifyGitHubSignature("wrong-secret", BODY, signature), false);
assert.equal(await verifyGitHubSignature("wrong-secret", BODY, VALID), false);
});

// ── Malformed signature formats ──────────────────────────────────────

test("rejects a signature without the sha256= prefix", async () => {
// Bare hex, no "sha256=" prefix.
const bareHex = createHmac("sha256", SECRET).update(BODY).digest("hex");
assert.equal(await verifyGitHubSignature(SECRET, BODY, bareHex), false);
});

test("rejects an empty signature string", async () => {
assert.equal(await verifyGitHubSignature(SECRET, BODY, ""), false);
});

test("rejects a signature without the sha256= prefix", async () => {
// Same hex digest but missing the "sha256=" prefix.
const hexOnly = createHmac("sha256", SECRET).update(BODY).digest("hex");
assert.equal(await verifyGitHubSignature(SECRET, BODY, hexOnly), false);
test("rejects a sha256=-prefixed hex of the wrong length", async () => {
assert.equal(await verifyGitHubSignature(SECRET, BODY, "sha256=deadbeef"), false);
});

test("rejects a sha256=-prefixed hex of the wrong length", async () => {
// Truncated digest (valid prefix, too few hex chars).
const shortHex = createHmac("sha256", SECRET).update(BODY).digest("hex").slice(0, 32);
assert.equal(await verifyGitHubSignature(SECRET, BODY, "sha256=" + shortHex), false);
test("rejects a sha256=-prefixed non-hex string of the right length", async () => {
assert.equal(
await verifyGitHubSignature(SECRET, BODY, "sha256=" + "z".repeat(64)),
false,
);
});
Loading