Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: signSync, verifySync #211

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
21 changes: 0 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,6 @@ const {
await sign("mysecret", eventPayloadString);
// resolves with a string like "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"

await sign({ secret: "mysecret", algorithm: "sha1" }, eventPayloadString);
// resolves with a string like "sha1=d03207e4b030cf234e3447bac4d93add4c6643d8"

await verify("mysecret", eventPayloadString, "sha256=486d27...");
// resolves with true or false

Expand All @@ -87,7 +84,6 @@ await verifyWithFallback("mysecret", eventPayloadString, "sha256=486d27...", ["o

```js
await sign(secret, eventPayloadString);
await sign({ secret, algorithm }, eventPayloadString);
```

<table width="100%">
Expand All @@ -103,23 +99,6 @@ await sign({ secret, algorithm }, eventPayloadString);
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`. `sha1` is supported for legacy reasons. GitHub Enterprise Server 2.22 and older do not send the `X-Hub-Signature-256` header. Defaults to `sha256`.

Learn more at [Validating payloads from GitHub](https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks#validating-payloads-from-github)

</td>
</tr>
<tr>
<td>
<code>
Expand Down
21 changes: 15 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
export { sign } from "./node/sign.js";
import { verify } from "./node/verify.js";
export { verify };
export { sign, signSync } from "./node/sign.js";
import { verifySync } from "./node/verify.js";
export { verify, verifySync } from "./node/verify.js";

export async function verifyWithFallback(
secret: string,
payload: string,
signature: string,
additionalSecrets: undefined | string[],
): Promise<any> {
const firstPass = await verify(secret, payload, signature);
): Promise<boolean> {
return verifyWithFallbackSync(secret, payload, signature, additionalSecrets);
}

export function verifyWithFallbackSync(
secret: string,
payload: string,
signature: string,
additionalSecrets: undefined | string[],
): boolean {
const firstPass = verifySync(secret, payload, signature);

if (firstPass) {
return true;
}

if (additionalSecrets !== undefined) {
for (const s of additionalSecrets) {
const v: boolean = await verify(s, payload, signature);
const v: boolean = verifySync(s, payload, signature);
if (v) {
return v;
}
Expand Down
29 changes: 10 additions & 19 deletions src/node/sign.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,25 @@
import { createHmac } from "node:crypto";
import { Algorithm, type SignOptions } from "../types.js";
import { VERSION } from "../version.js";

export async function sign(
options: SignOptions | string,
payload: string,
secret: string | Buffer,
payload: string | Buffer,
): Promise<string> {
const { secret, algorithm } =
typeof options === "object"
? {
secret: options.secret,
algorithm: options.algorithm || Algorithm.SHA256,
}
: { secret: options, algorithm: Algorithm.SHA256 };
return signSync(secret, payload);
}

export function signSync(
secret: string | Buffer,
payload: string | Buffer,
): string {
if (!secret || !payload) {
throw new TypeError(
"[@octokit/webhooks-methods] secret & payload required for sign()",
);
}

if (!Object.values(Algorithm).includes(algorithm as Algorithm)) {
throw new TypeError(
`[@octokit/webhooks] Algorithm ${algorithm} is not supported. Must be 'sha1' or 'sha256'`,
);
}

return `${algorithm}=${createHmac(algorithm, secret)
.update(payload)
.digest("hex")}`;
return `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`;
}

sign.VERSION = VERSION;
signSync.VERSION = VERSION;
34 changes: 22 additions & 12 deletions src/node/verify.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
import { timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { Buffer } from "node:buffer";

import { sign } from "./sign.js";
import { VERSION } from "../version.js";
import { getAlgorithm } from "../utils.js";
import { isValidSignaturePrefix } from "../utils.js";

export async function verify(
secret: string,
eventPayload: string,
secret: string | Buffer,
eventPayload: string | Buffer,
signature: string,
): Promise<boolean> {
return verifySync(secret, eventPayload, signature);
}

export function verifySync(
secret: string | Buffer,
eventPayload: string | Buffer,
signature: string,
): boolean {
if (!secret || !eventPayload || !signature) {
throw new TypeError(
"[@octokit/webhooks-methods] secret, eventPayload & signature required",
);
}

const signatureBuffer = Buffer.from(signature);
const algorithm = getAlgorithm(signature);

const verificationBuffer = Buffer.from(
await sign({ secret, algorithm }, eventPayload),
);
if (isValidSignaturePrefix(signature) === false) {
return false;
}
const signatureBuffer = Buffer.from(signature.slice(7), "hex");

if (signatureBuffer.length !== verificationBuffer.length) {
if (signatureBuffer.length !== 32) {
return false;
}

const verificationBuffer = createHmac("sha256", secret)
.update(eventPayload)
.digest().buffer as Buffer;

// constant time comparison to prevent timing attacks
// https://stackoverflow.com/a/31096242/206879
// https://en.wikipedia.org/wiki/Timing_attack
return timingSafeEqual(signatureBuffer, verificationBuffer);
}

verify.VERSION = VERSION;
verifySync.VERSION = VERSION;
8 changes: 0 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
export enum Algorithm {
SHA1 = "sha1",
SHA256 = "sha256",
}

export type AlgorithmLike = Algorithm | "sha1" | "sha256";

export type SignOptions = {
secret: string;
algorithm?: AlgorithmLike;
};
13 changes: 11 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
export const getAlgorithm = (signature: string) => {
return signature.startsWith("sha256=") ? "sha256" : "sha1";
export const isValidSignaturePrefix = (signature: string) => {
return (
signature.length === 71 &&
signature[0] === "s" &&
signature[1] === "h" &&
signature[2] === "a" &&
signature[3] === "2" &&
signature[4] === "5" &&
signature[5] === "6" &&
signature[6] === "="
);
};
41 changes: 7 additions & 34 deletions src/web.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { Algorithm, type AlgorithmLike, type SignOptions } from "./types.js";
import { getAlgorithm } from "./utils.js";

const enc = new TextEncoder();

function hexToUInt8Array(string: string) {
Expand All @@ -21,58 +18,35 @@ function UInt8ArrayToHex(signature: ArrayBuffer) {
.join("");
}

function getHMACHashName(algorithm: AlgorithmLike) {
return (
{
[Algorithm.SHA1]: "SHA-1",
[Algorithm.SHA256]: "SHA-256",
} as { [key in Algorithm]: string }
)[algorithm];
}

async function importKey(secret: string, algorithm: AlgorithmLike) {
async function importKey(secret: string) {
// ref: https://developer.mozilla.org/en-US/docs/Web/API/HmacImportParams
return crypto.subtle.importKey(
"raw", // raw format of the key - should be Uint8Array
enc.encode(secret),
{
// algorithm details
name: "HMAC",
hash: { name: getHMACHashName(algorithm) },
hash: { name: "SHA-256" },
},
false, // export = false
["sign", "verify"], // what this key can do
);
}

export async function sign(options: SignOptions | string, payload: string) {
const { secret, algorithm } =
typeof options === "object"
? {
secret: options.secret,
algorithm: options.algorithm || Algorithm.SHA256,
}
: { secret: options, algorithm: Algorithm.SHA256 };

export async function sign(secret: string, payload: string) {
if (!secret || !payload) {
throw new TypeError(
"[@octokit/webhooks-methods] secret & payload required for sign()",
);
}

if (!Object.values(Algorithm).includes(algorithm as Algorithm)) {
throw new TypeError(
`[@octokit/webhooks] Algorithm ${algorithm} is not supported. Must be 'sha1' or 'sha256'`,
);
}

const signature = await crypto.subtle.sign(
"HMAC",
await importKey(secret, algorithm),
await importKey(secret),
enc.encode(payload),
);

return `${algorithm}=${UInt8ArrayToHex(signature)}`;
return `sha256=${UInt8ArrayToHex(signature)}`;
}

export async function verify(
Expand All @@ -86,11 +60,10 @@ export async function verify(
);
}

const algorithm = getAlgorithm(signature);
return await crypto.subtle.verify(
"HMAC",
await importKey(secret, algorithm),
hexToUInt8Array(signature.replace(`${algorithm}=`, "")),
await importKey(secret),
hexToUInt8Array(signature.replace(`sha256=`, "")),
enc.encode(eventPayload),
);
}
67 changes: 45 additions & 22 deletions test/sign.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sign } from "../src/index.ts";
import { sign, signSync } from "../src/index.ts";

const eventPayload = {
foo: "bar",
Expand Down Expand Up @@ -35,45 +35,51 @@ describe("sign", () => {
);
});

test("sign({secret, algorithm}) throws with invalid algorithm", async () => {
await expect(() =>
// @ts-expect-error
sign({ secret, algorithm: "sha2" }, eventPayload),
).rejects.toThrow(
"[@octokit/webhooks] Algorithm sha2 is not supported. Must be 'sha1' or 'sha256'",
);
});

describe("with eventPayload as string", () => {
describe("returns expected sha1 signature", () => {
describe("with secret as Buffer", () => {
describe("returns expected sha256 signature", () => {
test("sign(secret, eventPayload)", async () => {
const signature = await sign(secret, JSON.stringify(eventPayload));
const signature = await sign(
Buffer.from(secret),
JSON.stringify(eventPayload),
);
expect(signature).toBe(
"sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3",
);
});
});
});

test("sign({secret}, eventPayload)", async () => {
const signature = await sign({ secret }, JSON.stringify(eventPayload));
describe("with eventPayload as string", () => {
describe("returns expected sha256 signature", () => {
test("sign(secret, eventPayload)", async () => {
const signature = await sign(secret, JSON.stringify(eventPayload));
expect(signature).toBe(
"sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3",
);
});
});
});

test("sign({secret, algorithm: 'sha1'}, eventPayload)", async () => {
describe("with eventPayload as Buffer", () => {
describe("returns expected sha256 signature", () => {
test("sign(secret, eventPayload)", async () => {
const signature = await sign(
{ secret, algorithm: "sha1" },
JSON.stringify(eventPayload),
secret,
Buffer.from(JSON.stringify(eventPayload)),
);
expect(signature).toBe(
"sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3",
);
expect(signature).toBe("sha1=d03207e4b030cf234e3447bac4d93add4c6643d8");
});
});
});

describe("with eventPayload and secret as Buffer", () => {
describe("returns expected sha256 signature", () => {
test("sign({secret, algorithm: 'sha256'}, eventPayload)", async () => {
test("sign(secret, eventPayload)", async () => {
const signature = await sign(
{ secret, algorithm: "sha256" },
JSON.stringify(eventPayload),
Buffer.from(secret),
Buffer.from(JSON.stringify(eventPayload)),
);
expect(signature).toBe(
"sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3",
Expand All @@ -82,3 +88,20 @@ describe("sign", () => {
});
});
});

describe("signSync", () => {
it("is a function", () => {
expect(signSync).toBeInstanceOf(Function);
});

it("signSync.VERSION is set", () => {
expect(signSync.VERSION).toEqual("0.0.0-development");
});

test("signSync(secret, eventPayload)", () => {
const signature = signSync(secret, JSON.stringify(eventPayload));
expect(signature).toBe(
"sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3",
);
});
});
Loading
Loading