Readme

This val has been created to avoid certain shortcomings of @vtdocs.verifyGithubWebhookSignature. So it was created as a mix/evolution of two sources:

This code is covered by tests which you can copy to run them, see @karfau.test_SignatureCheck

This val does not contain any val.town specific code (@-imports, console.email...), so it should be possible to run in Deno as is, potentially even in modern browsers (that support crypto and TextEncoder and modern ES syntax).

Usage

const myGithubWebhook = (req: Request) => { const {verify} = @karfau.SignatureCheck(); // you have to call it to get the verify function! const body = await req.text(); const signature = req.headers.get("X-Hub-Signature-256"); const verified = await verify( {payload:body, signature}, @me.secrets.myGithubWebhookSecret, // optionally provide fallback secrets (as many as needed) // @me.secrets.myGithubWebhookSecretFallback ); if (!verified) { return new Response(`Not verified`, 401); } const payload = JSON.parse(body); // actually do things in your webhook };

By default the reason for failing verification is logged to console.error, but you can pass it a different handler:

const {verify} = @karfau.SignatureCheck((reason) => { throw new Error(reason); });

(be aware that it will silently fail if you don't try catch it in an endpoint and the return code will be 502)

Why

@vtdocs.verifyGithubWebhookSignature has the following issues:

  • it relies on the verify method of the outdated @octokit/webhooks-methods@3.0.2 which has (at least) two bugs that can make a difference when used in a webhook
    • it can throws errors instead of just returning false, which can be triggered by sending an invalid signature
    • it can be lured into checking a SHA1 signature if the signature header starts with sha1=
  • you need to pass the secret and payload as argument to the val, which makes them appear in the evaluation logs you produce (they are only visible for the author of the val if you run them as an API, but it still feels odd to see the secret in the evaluation logs.)
  • parameters are all of type string and the order can be confused
  • you can not use fallback secrets for rotating
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
export function SignatureCheck(
onInvalid: (reason: string) => void = console.error,
) {
const algorithm = { name: "HMAC", hash: { name: "SHA-256" } };
const extractable = false;
const encoder = new TextEncoder();
const hexToUInt8Array = (string: string) =>
new Uint8Array(
(string.match(/[\da-fA-F]{2}/g) || []).map((s) => parseInt(s, 16)),
);
const verify = async (input: {
payload: string;
signature: string;
}, ...secrets: string[]): Promise<boolean> => {
const [secret, ...fallbackSecrets] = secrets;
try {
if (!input || !input.payload || !input.signature || !secret) {
throw "received at least one falsy argument";
}
const { payload, signature } = input;
const sigHex = signature.replace(/^sha\d+=/, "");
if (!/^([\da-fA-F]{2})+$/.test(sigHex)) {
throw "signature not matching expected pattern";
}
const keyBytes = encoder.encode(secret);
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
algorithm,
extractable,
["sign", "verify"],
);
const sigBytes = hexToUInt8Array(sigHex);
const dataBytes = encoder.encode(payload);
const equal = await crypto.subtle.verify(
algorithm.name,
key,
sigBytes,
dataBytes,
);
if (!equal) {
throw "crypto.subtle.verify resolved false";
}
return true;
}
catch (error) {
if (fallbackSecrets.length > 0) {
return verify(input, ...fallbackSecrets);
}
onInvalid("[SignatureCheck.verify] " + error);
return false;
}
};
return { verify };
}
Val Town is a social website to write and deploy JavaScript.
Build APIs and schedule functions from your browser.
Comments
Nobody has commented on this val yet: be the first!
October 23, 2023