Repository: octokit/webhooks.js (ts)

Issue:
support SHA-256 signature verification headers from Webhooks
follow up to https://github.com/probot/probot/issues/1379 /cc @gwestersf 

> **Is your feature request related to a problem? Please describe.**
> 
> GitHub will soon begin sending SHA-256 signature headers with Webhooks, when configured with a secret. The header will look like:
> 
> `X-Hub-Signature-256: sha-256=kljsdfjkldasjfkaejkfwekuweur3298r2398r89fdsuf3`
> 
> **Describe the solution you'd like**
>
> Probot should use the SHA-256 header instead of the SHA-1 header for verification. Note that both headers will be sent, and there is no deprecation date on SHA-1 support at this time.

I would start out by creating a test. 

Here is where we currently calculate the signature using sha-1: https://github.com/octokit/webhooks.js/blob/716ee636fc73231b64659cca5bc257439d664ed3/src/sign/index.ts#L11

The signatures have `sha1=` / `sha256=` prefixes respectively, they also have different lenghts. We shouldn't need to introduce any new APIs to differentiate the two. We should be able to implement both with full backwards compatibility

Pull request description:
feat(sign): support sha256 algorithm
## 📝 Summary
<!--- Provide a general summary of your changes, and if you feel that the commit comments are not descriptive enough, give more detail of your changes -->
`sign()` supports 
* `sha256` signature
* keeps retrocompatibility with `sha1` signature
 
## ⛱ Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
Fix #320

Patch:
diff --git a/src/middleware/middleware.ts b/src/middleware/middleware.ts
index d9985ec..fbbb093 100644
--- a/src/middleware/middleware.ts
+++ b/src/middleware/middleware.ts
@@ -42,7 +42,8 @@ export function middleware(
   }
 
   const eventName = request.headers["x-github-event"] as WebhookEvents;
-  const signature = request.headers["x-hub-signature"] as string;
+  const signatureSHA1 = request.headers["x-hub-signature"] as string;
+  const signatureSHA256 = request.headers["x-hub-signature-256"] as string;
   const id = request.headers["x-github-delivery"] as string;
 
   debugWebhooks(`${eventName} event received (id: ${id})`);
@@ -62,7 +63,7 @@ export function middleware(
         id: id,
         name: eventName,
         payload,
-        signature,
+        signature: signatureSHA256 || signatureSHA1,
       });
     })
 
diff --git a/src/sign/README.md b/src/sign/README.md
index 2c960b4..6759362 100644
--- a/src/sign/README.md
+++ b/src/sign/README.md
@@ -5,6 +5,7 @@ The `sign` method can be used as a standalone method.
 ```js
 const { sign } = require('@octokit/webhooks')
 const signature = sign(secret, eventPayload)
+const signature = sign({ secret, algorithm }, eventPayload)
 // string like "sha1=d03207e4b030cf234e3447bac4d93add4c6643d8"
 ```
 
@@ -12,7 +13,7 @@ const signature = sign(secret, eventPayload)
   <tr>
     <td>
       <code>
-        options.secret
+        secret
       </code>
       <em>(String)</em>
     </td>
@@ -21,6 +22,19 @@ const signature = sign(secret, eventPayload)
       Secret as configured in GitHub Settings.
     </td>
   </tr>
+  <tr>
+    <td>
+      <code>
+        algorithm
+      </code>
+      <em>
+        (String)
+      </em>
+    </td>
+    <td>
+      Algorithm to calculate signature. Can be set to `sha1` or `sha256`. Defaults to `sha1`.
+    </td>
+  </tr>
   <tr>
     <td>
       <code>
diff --git a/src/sign/index.ts b/src/sign/index.ts
index d648406..cb0baff 100644
--- a/src/sign/index.ts
+++ b/src/sign/index.ts
@@ -1,14 +1,43 @@
 import { createHmac } from "crypto";
 
-export function sign(secret: string, payload: string | object): string {
+export enum Algorithm {
+  SHA1 = "sha1",
+  SHA256 = "sha256",
+}
+
+type SignOptions = {
+  secret: string;
+  algorithm?: string;
+};
+
+export function sign(
+  options: SignOptions | string,
+  payload: string | object
+): string {
+  const { secret, algorithm } =
+    typeof options === "string"
+      ? { secret: options, algorithm: Algorithm.SHA1 }
+      : {
+          secret: options.secret,
+          algorithm: options.algorithm || Algorithm.SHA1,
+        };
+
   // @ts-ignore throw friendly error message when required options are missing
   if (!secret || !payload) {
-    throw new TypeError("secret & payload required");
+    throw new TypeError("[@octokit/webhooks] secret & payload required");
+  }
+
+  if (!Object.values(Algorithm).includes(algorithm as Algorithm)) {
+    throw new TypeError(
+      `[@octokit/webhooks] Algorithm ${algorithm} is not supported. Must be  'sha1' or 'sha256'`
+    );
   }
 
   payload =
     typeof payload === "string" ? payload : toNormalizedJsonString(payload);
-  return "sha1=" + createHmac("sha1", secret).update(payload).digest("hex");
+  return `${algorithm}=${createHmac(algorithm, secret)
+    .update(payload)
+    .digest("hex")}`;
 }
 
 function toNormalizedJsonString(payload: object) {
diff --git a/src/verify/index.ts b/src/verify/index.ts
index 3c6dc8d..9bcf2ca 100644
--- a/src/verify/index.ts
+++ b/src/verify/index.ts
@@ -2,6 +2,10 @@ import { timingSafeEqual } from "crypto";
 import { Buffer } from "buffer";
 import { sign } from "../sign/index";
 
+const getAlgorithm = (signature: string) => {
+  return signature.startsWith("sha256=") ? "sha256" : "sha1";
+};
+
 export function verify(
   secret?: string,
   eventPayload?: object,
@@ -12,7 +16,10 @@ export function verify(
   }
 
   const signatureBuffer = Buffer.from(signature);
-  const verificationBuffer = Buffer.from(sign(secret, eventPayload));
+  const algorithm = getAlgorithm(signature);
+  const verificationBuffer = Buffer.from(
+    sign({ secret, algorithm }, eventPayload)
+  );
 
   if (signatureBuffer.length !== verificationBuffer.length) {
     return false;


Test patch:
diff --git a/test/integration/server-test.ts b/test/integration/server-test.ts
index 2a31495..f642ff3 100644
--- a/test/integration/server-test.ts
+++ b/test/integration/server-test.ts
@@ -6,6 +6,10 @@ import { promisify } from "util";
 import { Webhooks } from "../../src";
 import pushEventPayload from "../fixtures/push-payload.json";
 
+const signatureSha1 = "sha1=f4d795e69b5d03c139cc6ea991ad3e5762d13e2f";
+const signatureSha256 =
+  "sha256=2b49327af77d51c4c23700118b11d15c81d90c3bd57dafbbe32eb50085ce67e0";
+
 describe("server-test", () => {
   let availablePort: number;
 
@@ -47,6 +51,77 @@ describe("server-test", () => {
       .finally(() => server.close(t));
   });
 
+  test("POST / with push event payload with wrong sha1 but right sha256", (t) => {
+    expect.assertions(2);
+
+    const api = new Webhooks({
+      secret: "mysecret",
+    });
+    const server = http.createServer(api.middleware);
+
+    api.on("push", (event) => {
+      expect(event.id).toBe("123e4567-e89b-12d3-a456-426655440000");
+    });
+
+    promisify(server.listen.bind(server))(availablePort)
+
+      .then(() => {
+        return axios.post(
+          `http://localhost:${availablePort}`,
+          pushEventPayload,
+          {
+            headers: {
+              "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
+              "X-GitHub-Event": "push",
+              "X-Hub-Signature": "ignored",
+              "X-Hub-Signature-256": signatureSha256,
+            },
+          }
+        );
+      })
+
+      .then((result: AxiosResponse) => {
+        expect(result.status).toBe(200);
+      })
+      .catch((e) => expect(e instanceof Error).toBeTruthy())
+      .finally(() => server.close(t));
+  });
+
+  test("POST / with push event payload with only sha1", (t) => {
+    expect.assertions(2);
+
+    const api = new Webhooks({
+      secret: "mysecret",
+    });
+    const server = http.createServer(api.middleware);
+
+    api.on("push", (event) => {
+      expect(event.id).toBe("123e4567-e89b-12d3-a456-426655440000");
+    });
+
+    promisify(server.listen.bind(server))(availablePort)
+
+      .then(() => {
+        return axios.post(
+          `http://localhost:${availablePort}`,
+          pushEventPayload,
+          {
+            headers: {
+              "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
+              "X-GitHub-Event": "push",
+              "X-Hub-Signature": signatureSha1,
+            },
+          }
+        );
+      })
+
+      .then((result: AxiosResponse) => {
+        expect(result.status).toBe(200);
+      })
+      .catch((e) => expect(e instanceof Error).toBeTruthy())
+      .finally(() => server.close(t));
+  });
+
   test("POST / with push event payload", (t) => {
     expect.assertions(2);
 
@@ -69,8 +144,8 @@ describe("server-test", () => {
             headers: {
               "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
               "X-GitHub-Event": "push",
-              "X-Hub-Signature":
-                "sha1=f4d795e69b5d03c139cc6ea991ad3e5762d13e2f",
+              "X-Hub-Signature": "sha1=foo",
+              "X-Hub-Signature-256": signatureSha256,
             },
           }
         );
@@ -120,8 +195,8 @@ describe("server-test", () => {
             headers: {
               "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
               "X-GitHub-Event": "push",
-              "X-Hub-Signature":
-                "sha1=f4d795e69b5d03c139cc6ea991ad3e5762d13e2f",
+              "X-Hub-Signature": signatureSha1,
+              "X-Hub-Signature-256": signatureSha256,
             },
           }
         );
@@ -190,6 +265,7 @@ describe("server-test", () => {
               "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
               "X-GitHub-Event": "push",
               "X-Hub-Signature": "sha1=foo",
+              "X-Hub-Signature-256": "sha256=foo",
             },
           }
         );
@@ -227,8 +303,8 @@ describe("server-test", () => {
             headers: {
               "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
               "X-GitHub-Event": "push",
-              "X-Hub-Signature":
-                "sha1=f4d795e69b5d03c139cc6ea991ad3e5762d13e2f",
+              "X-Hub-Signature": signatureSha1,
+              "X-Hub-Signature-256": signatureSha256,
             },
           }
         );
@@ -271,8 +347,8 @@ describe("server-test", () => {
             headers: {
               "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000",
               "X-GitHub-Event": "push",
-              "X-Hub-Signature":
-                "sha1=f4d795e69b5d03c139cc6ea991ad3e5762d13e2f",
+              "X-Hub-Signature": signatureSha1,
+              "X-Hub-Signature-256": signatureSha256,
             },
           }
         );
diff --git a/test/integration/sign-test.ts b/test/integration/sign-test.ts
index 2a7228c..1fff22a 100644
--- a/test/integration/sign-test.ts
+++ b/test/integration/sign-test.ts
@@ -20,12 +20,71 @@ test("sign(secret) without eventPayload throws", () => {
   expect(() => sign.bind(null, secret)()).toThrow();
 });
 
-test("sign(secret, eventPayload) with eventPayload as object returns expected signature", () => {
-  const signature = sign(secret, eventPayload);
-  expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+test("sign({secret, algorithm}) with invalid algorithm throws", () => {
+  // @ts-ignore
+  expect(() =>
+    sign.bind(null, { secret, algorithm: "sha2" }, eventPayload)()
+  ).toThrow();
 });
 
-test("sign(secret, eventPayload) with eventPayload as string returns expected signature", () => {
-  const signature = sign(secret, JSON.stringify(eventPayload));
-  expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+describe("with eventPayload as object", () => {
+  describe("resturns expected sha1 signature", () => {
+    test("sign(secret, eventPayload)", () => {
+      const signature = sign(secret, eventPayload);
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+
+    test("sign({secret}, eventPayload)", () => {
+      const signature = sign({ secret }, eventPayload);
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+
+    test("sign({secret, algorithm: 'sha1'}, eventPayload)", () => {
+      const signature = sign({ secret, algorithm: "sha1" }, eventPayload);
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+  });
+
+  describe("resturns expected sha256 signature", () => {
+    test("sign({secret, algorithm}, eventPayload)", () => {
+      const signature = sign({ secret, algorithm: "sha256" }, eventPayload);
+      expect(signature).toBe(
+        "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"
+      );
+    });
+  });
+});
+
+describe("with eventPayload as string", () => {
+  describe("resturns expected sha1 signature", () => {
+    test("sign(secret, eventPayload)", () => {
+      const signature = sign(secret, JSON.stringify(eventPayload));
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+
+    test("sign({secret}, eventPayload)", () => {
+      const signature = sign({ secret }, JSON.stringify(eventPayload));
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+
+    test("sign({secret, algorithm: 'sha1'}, eventPayload)", () => {
+      const signature = sign(
+        { secret, algorithm: "sha1" },
+        JSON.stringify(eventPayload)
+      );
+      expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
+    });
+  });
+
+  describe("returns expected sha256 signature", () => {
+    test("sign({secret, algorithm: 'sha256'}, eventPayload)", () => {
+      const signature = sign(
+        { secret, algorithm: "sha256" },
+        JSON.stringify(eventPayload)
+      );
+      expect(signature).toBe(
+        "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"
+      );
+    });
+  });
 });
diff --git a/test/integration/verify-test.ts b/test/integration/verify-test.ts
index ba6ff73..163a94e 100644
--- a/test/integration/verify-test.ts
+++ b/test/integration/verify-test.ts
@@ -4,7 +4,9 @@ const eventPayload = {
   foo: "bar",
 };
 const secret = "mysecret";
-const signature = "sha1=d03207e4b030cf234e3447bac4d93add4c6643d8";
+const signatureSHA1 = "sha1=d03207e4b030cf234e3447bac4d93add4c6643d8";
+const signatureSHA256 =
+  "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3";
 
 test("verify() without options throws", () => {
   expect(() => verify()).toThrow();
@@ -22,22 +24,22 @@ test("verify(secret, eventPayload) without options.signature throws", () => {
   expect(() => verify.bind(null, secret, eventPayload)()).toThrow();
 });
 
-test("verify(secret, eventPayload, signature) returns true for correct signature", () => {
-  const signatureMatches = verify(secret, eventPayload, signature);
+test("verify(secret, eventPayload, signatureSHA1) returns true for correct signature", () => {
+  const signatureMatches = verify(secret, eventPayload, signatureSHA1);
   expect(signatureMatches).toBe(true);
 });
 
-test("verify(secret, eventPayload, signature) returns false for incorrect signature", () => {
+test("verify(secret, eventPayload, signatureSHA1) returns false for incorrect signature", () => {
   const signatureMatches = verify(secret, eventPayload, "foo");
   expect(signatureMatches).toBe(false);
 });
 
-test("verify(secret, eventPayload, signature) returns false for correct secret", () => {
-  const signatureMatches = verify("foo", eventPayload, signature);
+test("verify(secret, eventPayload, signatureSHA1) returns false for correct secret", () => {
+  const signatureMatches = verify("foo", eventPayload, signatureSHA1);
   expect(signatureMatches).toBe(false);
 });
 
-test("verify(secret, eventPayload, signature) returns true if eventPayload contains (#71)", () => {
+test("verify(secret, eventPayload, signatureSHA1) returns true if eventPayload contains special characters (#71)", () => {
   // https://github.com/octokit/webhooks.js/issues/71
   const signatureMatchesLowerCaseSequence = verify(
     "development",
@@ -64,3 +66,46 @@ test("verify(secret, eventPayload, signature) returns true if eventPayload conta
   );
   expect(signatureMatchesEscapedSequence).toBe(true);
 });
+
+test("verify(secret, eventPayload, signatureSHA256) returns true for correct signature", () => {
+  const signatureMatches = verify(secret, eventPayload, signatureSHA256);
+  expect(signatureMatches).toBe(true);
+});
+
+test("verify(secret, eventPayload, signatureSHA256) returns false for incorrect signature", () => {
+  const signatureMatches = verify(secret, eventPayload, "foo");
+  expect(signatureMatches).toBe(false);
+});
+
+test("verify(secret, eventPayload, signatureSHA256) returns false for correct secret", () => {
+  const signatureMatches = verify("foo", eventPayload, signatureSHA256);
+  expect(signatureMatches).toBe(false);
+});
+
+test("verify(secret, eventPayload, signatureSHA256) returns true if eventPayload contains special characters (#71)", () => {
+  // https://github.com/octokit/webhooks.js/issues/71
+  const signatureMatchesLowerCaseSequence = verify(
+    "development",
+    {
+      foo: "Foo\n\u001b[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001b[0m\u001b[2K",
+    },
+    "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c"
+  );
+  expect(signatureMatchesLowerCaseSequence).toBe(true);
+  const signatureMatchesUpperCaseSequence = verify(
+    "development",
+    {
+      foo: "Foo\n\u001B[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001B[0m\u001B[2K",
+    },
+    "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c"
+  );
+  expect(signatureMatchesUpperCaseSequence).toBe(true);
+  const signatureMatchesEscapedSequence = verify(
+    "development",
+    {
+      foo: "\\u001b",
+    },
+    "sha256=6f8326efbacfbd04e870cea25b5652e635be8c9807f2fd5348ef60753c9e96ed"
+  );
+  expect(signatureMatchesEscapedSequence).toBe(true);
+});
diff --git a/test/typescript-validate.ts b/test/typescript-validate.ts
index f3a9089..2246adf 100644
--- a/test/typescript-validate.ts
+++ b/test/typescript-validate.ts
@@ -66,6 +66,9 @@ export default async function () {
   });
 
   sign("randomSecret", {});
+  sign({ secret: "randomSecret" }, {});
+  sign({ secret: "randomSecret", algorithm: "sha1" }, {});
+  sign({ secret: "randomSecret", algorithm: "sha256" }, {});
 
   verify("randomSecret", {}, "randomSignature");