From c4bb276a28396bf8324e728909e2c805451e3afc Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 17:53:30 +0200 Subject: [PATCH 01/41] feat: refactor webhooks-methods.js --- package.json | 1 + src/common/hmac-sha256.ts | 44 ++++++++++ src/common/verify-signature.ts | 12 +++ src/index.ts | 39 ++++----- src/methods/sign.ts | 45 ++++++++++ src/methods/verify-with-fallback.ts | 34 ++++++++ src/methods/verify.ts | 51 ++++++++++++ src/node/sha256.ts | 5 ++ src/node/sign.ts | 22 ----- src/node/timing-safe-equal.ts | 8 ++ src/node/uint8array-to-hex.ts | 5 ++ src/node/verify.ts | 38 --------- src/types.ts | 20 +++++ src/web.ts | 123 +++++----------------------- src/web/sha256.ts | 3 + src/web/timing-safe-equal.ts | 15 ++++ src/web/uint8array-to-hex.ts | 19 +++++ test/deno/deno.lock | 23 ++++++ 18 files changed, 318 insertions(+), 189 deletions(-) create mode 100644 src/common/hmac-sha256.ts create mode 100644 src/common/verify-signature.ts create mode 100644 src/methods/sign.ts create mode 100644 src/methods/verify-with-fallback.ts create mode 100644 src/methods/verify.ts create mode 100644 src/node/sha256.ts delete mode 100644 src/node/sign.ts create mode 100644 src/node/timing-safe-equal.ts create mode 100644 src/node/uint8array-to-hex.ts delete mode 100644 src/node/verify.ts create mode 100644 src/types.ts create mode 100644 src/web/sha256.ts create mode 100644 src/web/timing-safe-equal.ts create mode 100644 src/web/uint8array-to-hex.ts create mode 100644 test/deno/deno.lock diff --git a/package.json b/package.json index c59fe8e6..e543ba7e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:node": "vitest run --coverage", "test:web": "npm run test:deno && npm run test:browser", "pretest:web": "npm run -s build", + "test:bun": "bun test", "test:deno": "cd test/deno && deno test", "test:browser": "node test/browser-test.js" }, diff --git a/src/common/hmac-sha256.ts b/src/common/hmac-sha256.ts new file mode 100644 index 00000000..c7378239 --- /dev/null +++ b/src/common/hmac-sha256.ts @@ -0,0 +1,44 @@ +type HmacSha256Options = { + key: Uint8Array; + data: Uint8Array; + sha256: (data: Uint8Array) => Uint8Array | Promise; +}; + +const blockSize = 64; + +export async function hmacSha256({ + sha256, + data, + key, +}: HmacSha256Options): Promise { + if (!key || !data) { + throw new TypeError( + "[@octokit/webhooks-methods] key & data required for hmacSha256()", + ); + } + + if (!(key instanceof Uint8Array) || !(data instanceof Uint8Array)) { + throw new TypeError( + "[@octokit/webhooks-methods] key & data must be Uint8Array", + ); + } + + if (key.length > blockSize) { + key = await sha256(key); + } else if (key.length < blockSize) { + const zeroBuffer = new Uint8Array(blockSize).fill(0); + zeroBuffer.set(key, 0); + key = zeroBuffer; + } + + const oKeyPad = new Uint8Array(blockSize); + const iKeyPad = new Uint8Array(blockSize); + + for (let i = 0; i < blockSize; i++) { + oKeyPad[i] = key[i] ^ 0x5c; + iKeyPad[i] = key[i] ^ 0x36; + } + + const innerHash = await sha256(new Uint8Array([...iKeyPad, ...data])); + return sha256(new Uint8Array([...oKeyPad, ...innerHash])); +} diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts new file mode 100644 index 00000000..9c2900f8 --- /dev/null +++ b/src/common/verify-signature.ts @@ -0,0 +1,12 @@ +const hexRE = /^sha256=[\da-fA-F]{64}$/; + +/** + * Verifies if a given value is a valid SHA-256 signature. + * The signature must start with "sha256=" followed by a 64-character hexadecimal string. + * + * @param value - The value to verify. + * @returns {value is `sha256=${string}`} `true` if the value is a valid SHA-256 signature, `false` otherwise. + */ +export const verifySignature = RegExp.prototype.test.bind(hexRE) as ( + value: string, +) => value is `sha256=${string}`; diff --git a/src/index.ts b/src/index.ts index 5756ce16..66d2df0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,16 @@ -export { sign } from "./node/sign.js"; -import { verify } from "./node/verify.js"; -export { verify }; +import { verifyFactory } from "./methods/verify.js"; +import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; +import { signFactory } from "./methods/sign.js"; +import { VERSION } from "./version.js"; -export async function verifyWithFallback( - secret: string, - payload: string, - signature: string, - additionalSecrets: undefined | string[], -): Promise { - const firstPass = await verify(secret, payload, signature); +import { timingSafeEqual } from "./node/timing-safe-equal.js"; +import { sha256 } from "./node/sha256.js"; +import { uint8ArrayToHex } from "./node/uint8array-to-hex.js"; - if (firstPass) { - return true; - } - - if (additionalSecrets !== undefined) { - for (const s of additionalSecrets) { - const v: boolean = await verify(s, payload, signature); - if (v) { - return v; - } - } - } - - return false; -} +export const sign = signFactory({ sha256, uint8ArrayToHex }); +export const verify = verifyFactory({ + sign, + timingSafeEqual, +}); +export const verifyWithFallback = verifyWithFallbackFactory({ verify }); +export { VERSION }; diff --git a/src/methods/sign.ts b/src/methods/sign.ts new file mode 100644 index 00000000..b7df8fe4 --- /dev/null +++ b/src/methods/sign.ts @@ -0,0 +1,45 @@ +import { hmacSha256 } from "../common/hmac-sha256.js"; +import type { Signature, Signer } from "../types.js"; +import { VERSION } from "../version.js"; + +type SignerFactoryOptions = { + sha256: (data: Uint8Array) => Uint8Array | Promise; + uint8ArrayToHex: (value: Uint8Array) => string; +}; + +const algorithm = "sha256"; +const textEncoder = new TextEncoder(); + +export function signFactory({ + sha256, + uint8ArrayToHex, +}: SignerFactoryOptions): Signer { + const sign = async function sign( + secret: string, + payload: string, + ): Promise { + if (!secret || !payload) { + throw new TypeError( + "[@octokit/webhooks-methods] secret & payload required for sign()", + ); + } + + if (typeof payload !== "string") { + throw new TypeError( + "[@octokit/webhooks-methods] payload must be a string", + ); + } + + const secretBuffer = textEncoder.encode(secret); + + const signature = await hmacSha256({ + data: textEncoder.encode(payload), + key: secretBuffer, + sha256, + }); + + return `${algorithm}=${uint8ArrayToHex(signature)}`; + }; + sign.VERSION = VERSION; + return sign; +} diff --git a/src/methods/verify-with-fallback.ts b/src/methods/verify-with-fallback.ts new file mode 100644 index 00000000..1fbded81 --- /dev/null +++ b/src/methods/verify-with-fallback.ts @@ -0,0 +1,34 @@ +import type { Verifier, VerifyWithFallback } from "../types.js"; + +export function verifyWithFallbackFactory({ + verify, +}: { + verify: Verifier; +}): VerifyWithFallback { + const verifyWithFallback = async function verifyWithFallback( + secret: string, + payload: string, + signature: string, + additionalSecrets: undefined | string[], + ): Promise { + const firstPass = await verify(secret, payload, signature); + + if (firstPass) { + return true; + } + + if (additionalSecrets !== undefined) { + for (const s of additionalSecrets) { + const v = await verify(s, payload, signature); + if (v) { + return v; + } + } + } + + return false; + }; + + verifyWithFallback.VERSION = verify.VERSION; + return verifyWithFallback; +} diff --git a/src/methods/verify.ts b/src/methods/verify.ts new file mode 100644 index 00000000..ed844af6 --- /dev/null +++ b/src/methods/verify.ts @@ -0,0 +1,51 @@ +import { verifySignature } from "../common/verify-signature.js"; +import type { Signer, Verifier } from "../types.js"; +import { VERSION } from "../version.js"; + +type VerifierFactoryOptions = { + sign: Signer; + timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean; +}; + +const textEncoder = new TextEncoder(); + +export function verifyFactory({ + sign, + timingSafeEqual, +}: VerifierFactoryOptions): Verifier { + const verify: Verifier = async function verify( + secret: string, + eventPayload: string, + signature: string, + ): Promise { + if (!secret || !eventPayload || !signature) { + throw new TypeError( + "[@octokit/webhooks-methods] secret, eventPayload & signature required", + ); + } + + if (typeof eventPayload !== "string") { + throw new TypeError( + "[@octokit/webhooks-methods] eventPayload must be a string", + ); + } + + if (verifySignature(signature) === false) { + return false; + } + + const signatureBuffer = textEncoder.encode(signature); + const verificationBuffer = textEncoder.encode( + await sign(secret, eventPayload), + ); + + if (signatureBuffer.length !== verificationBuffer.length) { + return false; + } + + return timingSafeEqual(signatureBuffer, verificationBuffer); + }; + + verify.VERSION = VERSION; + return verify; +} diff --git a/src/node/sha256.ts b/src/node/sha256.ts new file mode 100644 index 00000000..a818c992 --- /dev/null +++ b/src/node/sha256.ts @@ -0,0 +1,5 @@ +import { hash } from "node:crypto"; + +export function sha256(input: Uint8Array): Uint8Array { + return hash("sha256", input, "buffer"); +} diff --git a/src/node/sign.ts b/src/node/sign.ts deleted file mode 100644 index b2a606cc..00000000 --- a/src/node/sign.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createHmac } from "node:crypto"; -import { VERSION } from "../version.js"; - -export async function sign(secret: string, payload: string): Promise { - if (!secret || !payload) { - throw new TypeError( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - } - - if (typeof payload !== "string") { - throw new TypeError("[@octokit/webhooks-methods] payload must be a string"); - } - - const algorithm = "sha256"; - - return `${algorithm}=${createHmac(algorithm, secret) - .update(payload) - .digest("hex")}`; -} - -sign.VERSION = VERSION; diff --git a/src/node/timing-safe-equal.ts b/src/node/timing-safe-equal.ts new file mode 100644 index 00000000..386d250b --- /dev/null +++ b/src/node/timing-safe-equal.ts @@ -0,0 +1,8 @@ +import { timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto"; + +export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + return cryptoTimingSafeEqual(a, b); +} diff --git a/src/node/uint8array-to-hex.ts b/src/node/uint8array-to-hex.ts new file mode 100644 index 00000000..e5cc29b9 --- /dev/null +++ b/src/node/uint8array-to-hex.ts @@ -0,0 +1,5 @@ +import { Buffer } from "node:buffer"; + +export function uint8ArrayToHex(value: ArrayBufferLike): string { + return Buffer.from(value).toString("hex"); +} diff --git a/src/node/verify.ts b/src/node/verify.ts deleted file mode 100644 index 816867d3..00000000 --- a/src/node/verify.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import { Buffer } from "node:buffer"; - -import { sign } from "./sign.js"; -import { VERSION } from "../version.js"; - -export async function verify( - secret: string, - eventPayload: string, - signature: string, -): Promise { - if (!secret || !eventPayload || !signature) { - throw new TypeError( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - } - - if (typeof eventPayload !== "string") { - throw new TypeError( - "[@octokit/webhooks-methods] eventPayload must be a string", - ); - } - - const signatureBuffer = Buffer.from(signature); - - const verificationBuffer = Buffer.from(await sign(secret, eventPayload)); - - if (signatureBuffer.length !== verificationBuffer.length) { - return false; - } - - // 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; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..639f6817 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,20 @@ +export type Signature = `sha256=${string}`; + +export type Signer = { + (secret: string, payload: string): Promise; + VERSION: string; +}; + +export type Verifier = { + (secret: string, eventPayload: string, signature: string): Promise; + VERSION: string; +}; + +export type VerifyWithFallback = { + ( + secret: string, + payload: string, + signature: string, + additionalSecrets?: undefined | string[], + ): Promise; +}; diff --git a/src/web.ts b/src/web.ts index d5fb4fc3..e7acbea4 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,104 +1,19 @@ -const enc = new TextEncoder(); - -function hexToUInt8Array(string: string) { - // convert string to pairs of 2 characters - const pairs = string.match(/[\dA-F]{2}/gi) as RegExpMatchArray; - - // convert the octets to integers - const integers = pairs.map(function (s) { - return parseInt(s, 16); - }); - - return new Uint8Array(integers); -} - -function UInt8ArrayToHex(signature: ArrayBuffer) { - return Array.prototype.map - .call(new Uint8Array(signature), (x) => x.toString(16).padStart(2, "0")) - .join(""); -} - -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: "SHA-256" }, - }, - false, // export = false - ["sign", "verify"], // what this key can do - ); -} - -export async function sign(secret: string, payload: string): Promise { - if (!secret || !payload) { - throw new TypeError( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - } - - if (typeof payload !== "string") { - throw new TypeError("[@octokit/webhooks-methods] payload must be a string"); - } - - const algorithm = "sha256"; - const signature = await crypto.subtle.sign( - "HMAC", - await importKey(secret), - enc.encode(payload), - ); - - return `${algorithm}=${UInt8ArrayToHex(signature)}`; -} - -export async function verify( - secret: string, - eventPayload: string, - signature: string, -) { - if (!secret || !eventPayload || !signature) { - throw new TypeError( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - } - - if (typeof eventPayload !== "string") { - throw new TypeError( - "[@octokit/webhooks-methods] eventPayload must be a string", - ); - } - - const algorithm = "sha256"; - return await crypto.subtle.verify( - "HMAC", - await importKey(secret), - hexToUInt8Array(signature.replace(`${algorithm}=`, "")), - enc.encode(eventPayload), - ); -} -export async function verifyWithFallback( - secret: string, - payload: string, - signature: string, - additionalSecrets: undefined | string[], -): Promise { - const firstPass = await verify(secret, payload, signature); - - if (firstPass) { - return true; - } - - if (additionalSecrets !== undefined) { - for (const s of additionalSecrets) { - const v: boolean = await verify(s, payload, signature); - if (v) { - return v; - } - } - } - - return false; -} +import { verifyFactory } from "./methods/verify.js"; +import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; +import { signFactory } from "./methods/sign.js"; +import { VERSION } from "./version.js"; + +import { sha256 } from "./web/sha256.js"; +import { timingSafeEqual } from "./web/timing-safe-equal.js"; +import { uint8ArrayToHex } from "./web/uint8array-to-hex.js"; + +export const sign = signFactory({ + sha256, + uint8ArrayToHex, +}); +export const verify = verifyFactory({ + sign, + timingSafeEqual, +}); +export const verifyWithFallback = verifyWithFallbackFactory({ verify }); +export { VERSION }; diff --git a/src/web/sha256.ts b/src/web/sha256.ts new file mode 100644 index 00000000..77cc4aee --- /dev/null +++ b/src/web/sha256.ts @@ -0,0 +1,3 @@ +export async function sha256(input: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest("SHA-256", input)); +} diff --git a/src/web/timing-safe-equal.ts b/src/web/timing-safe-equal.ts new file mode 100644 index 00000000..34a48646 --- /dev/null +++ b/src/web/timing-safe-equal.ts @@ -0,0 +1,15 @@ +// constant time comparison to prevent timing attacks +// https://stackoverflow.com/a/31096242/206879 +// https://en.wikipedia.org/wiki/Timing_attack +export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + var len = a.length; + var out = 0; + var i = -1; + while (++i < len) { + out |= a[i] ^ b[i]; + } + return out === 0; +} diff --git a/src/web/uint8array-to-hex.ts b/src/web/uint8array-to-hex.ts new file mode 100644 index 00000000..9d401f79 --- /dev/null +++ b/src/web/uint8array-to-hex.ts @@ -0,0 +1,19 @@ +const padding = "00000000"; + +export function uint8ArrayToHex(value: Uint8Array): string { + let digest = ""; + const view = new DataView(value.buffer, value.byteOffset, value.byteLength); + for (let i = 0; i < view.byteLength; i += 4) { + // We use getUint32 to reduce the number of iterations (notice the `i += 4`) + const value = view.getUint32(i); + // toString(16) will transform the integer into the corresponding hex string + // but will remove any initial "0" + const stringValue = value.toString(16); + // One Uint32 element is 4 bytes or 8 hex chars (it would also work with 4 + // chars for Uint16 and 2 chars for Uint8) + const paddedValue = (padding + stringValue).slice(-8); + digest += paddedValue; + } + + return digest; +} diff --git a/test/deno/deno.lock b/test/deno/deno.lock new file mode 100644 index 00000000..469120a9 --- /dev/null +++ b/test/deno/deno.lock @@ -0,0 +1,23 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.13", + "jsr:@std/internal@^1.0.6": "1.0.7" + }, + "jsr": { + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.7": { + "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1" + ] + } +} From 9fbd2e277164258944c5c32a53ca4b862a0d1cd0 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 18:05:00 +0200 Subject: [PATCH 02/41] implement concatUint8Array --- src/common/concat-uint8array.ts | 20 ++++++++++++++++++++ src/common/hmac-sha256.ts | 6 ++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/common/concat-uint8array.ts diff --git a/src/common/concat-uint8array.ts b/src/common/concat-uint8array.ts new file mode 100644 index 00000000..18c3c585 --- /dev/null +++ b/src/common/concat-uint8array.ts @@ -0,0 +1,20 @@ +export function concatUint8Array(...arrays: Uint8Array[]): Uint8Array { + const len = arrays.length; + if (len === 0) return new Uint8Array(0); + if (len === 1) return arrays[0]; + + let totalLength = 0; + // Calculate the total length of the resulting Uint8Array + for (let i = 0; i < len; i++) { + totalLength += arrays[i].length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + + for (let i = 0; i < len; i++) { + result.set(arrays[i], offset); + offset += arrays[i].length; + } + + return result; +} diff --git a/src/common/hmac-sha256.ts b/src/common/hmac-sha256.ts index c7378239..7d314256 100644 --- a/src/common/hmac-sha256.ts +++ b/src/common/hmac-sha256.ts @@ -1,3 +1,5 @@ +import { concatUint8Array } from "./concat-uint8array.js"; + type HmacSha256Options = { key: Uint8Array; data: Uint8Array; @@ -39,6 +41,6 @@ export async function hmacSha256({ iKeyPad[i] = key[i] ^ 0x36; } - const innerHash = await sha256(new Uint8Array([...iKeyPad, ...data])); - return sha256(new Uint8Array([...oKeyPad, ...innerHash])); + const innerHash = await sha256(concatUint8Array(iKeyPad, data)); + return sha256(concatUint8Array(oKeyPad, innerHash)); } From c383ec208feb5327fe06e3a861bc9c994467a9d7 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 18:23:33 +0200 Subject: [PATCH 03/41] fix testing --- test/deno/deno.json | 5 ----- test/deno/deno.lock | 23 ------------------- test/deno/web_test.ts | 52 ++++++++++++++++++++++++++----------------- test/sign.test.ts | 8 +++---- test/verify.test.ts | 10 ++++----- 5 files changed, 40 insertions(+), 58 deletions(-) delete mode 100644 test/deno/deno.json delete mode 100644 test/deno/deno.lock diff --git a/test/deno/deno.json b/test/deno/deno.json deleted file mode 100644 index c58a02f4..00000000 --- a/test/deno/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@std/assert": "jsr:@std/assert@^1.0.0" - } -} diff --git a/test/deno/deno.lock b/test/deno/deno.lock deleted file mode 100644 index 469120a9..00000000 --- a/test/deno/deno.lock +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.13", - "jsr:@std/internal@^1.0.6": "1.0.7" - }, - "jsr": { - "@std/assert@1.0.13": { - "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.7": { - "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1" - ] - } -} diff --git a/test/deno/web_test.ts b/test/deno/web_test.ts index 37729474..68b9b022 100644 --- a/test/deno/web_test.ts +++ b/test/deno/web_test.ts @@ -1,26 +1,36 @@ import { sign, verify, verifyWithFallback } from "../../pkg/dist-web/index.js"; -import { assertEquals } from "@std/assert"; +const assertEquals = (actual: any, expected: any) => { + if (actual !== expected) { + throw new Error(`Expected ${expected}, but got ${actual}`); + } +}; -Deno.test("sign", async () => { - const actual = await sign("secret", "data"); - const expected = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - assertEquals(actual, expected); -}); +if ("Deno" in globalThis) { + Deno.test("sign", async () => { + const actual = await sign("secret", "data"); + const expected = + "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; + assertEquals(actual, expected); + }); -Deno.test("verify", async () => { - const signature = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - const actual = await verify("secret", "data", signature); - const expected = true; - assertEquals(actual, expected); -}); + Deno.test("verify", async () => { + const signature = + "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; + const actual = await verify("secret", "data", signature); + const expected = true; + assertEquals(actual, expected); + }); -Deno.test("verify with fallback", async () => { - const signature = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - const actual = await verifyWithFallback("foo", "data", signature, ["secret"]); - const expected = true; - assertEquals(actual, expected); -}); + Deno.test("verify with fallback", async () => { + const signature = + "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; + const actual = await verifyWithFallback("foo", "data", signature, [ + "secret", + ]); + const expected = true; + assertEquals(actual, expected); + }); +} else { + console.log("This test is designed to run in Deno environment."); +} diff --git a/test/sign.test.ts b/test/sign.test.ts index 21d9869e..f63bef7a 100644 --- a/test/sign.test.ts +++ b/test/sign.test.ts @@ -17,21 +17,21 @@ describe("sign", () => { it("throws without options throws", async () => { // @ts-expect-error - await expect(() => sign()).rejects.toThrow( + await expect(sign()).rejects.toThrow( "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); it("throws without secret", async () => { // @ts-ignore - await expect(() => sign(undefined, eventPayload)).rejects.toThrow( + await expect(sign(undefined, eventPayload)).rejects.toThrow( "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); it("throws without eventPayload", async () => { // @ts-expect-error - await expect(() => sign(secret)).rejects.toThrow( + await expect(sign(secret)).rejects.toThrow( "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); @@ -49,7 +49,7 @@ describe("sign", () => { it("throws with eventPayload as object", async () => { // @ts-expect-error - await expect(() => sign(secret, eventPayload)).rejects.toThrow( + await expect(sign(secret, eventPayload)).rejects.toThrow( "[@octokit/webhooks-methods] payload must be a string", ); }); diff --git a/test/verify.test.ts b/test/verify.test.ts index ad431fc5..bc881656 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -19,28 +19,28 @@ describe("verify", () => { it("verify() without options throws", async () => { // @ts-expect-error - await expect(() => verify()).rejects.toThrow( + await expect(verify()).rejects.toThrow( "[@octokit/webhooks-methods] secret, eventPayload & signature required", ); }); it("verify(undefined, eventPayload) without secret throws", async () => { // @ts-expect-error - await expect(() => verify(undefined, eventPayload)).rejects.toThrow( + await expect(verify(undefined, eventPayload)).rejects.toThrow( "[@octokit/webhooks-methods] secret, eventPayload & signature required", ); }); it("verify(secret) without eventPayload throws", async () => { // @ts-expect-error - await expect(() => verify(secret)).rejects.toThrow( + await expect(verify(secret)).rejects.toThrow( "[@octokit/webhooks-methods] secret, eventPayload & signature required", ); }); it("verify(secret, eventPayload) without options.signature throws", async () => { // @ts-expect-error - await expect(() => verify(secret, eventPayload)).rejects.toThrow( + await expect(verify(secret, eventPayload)).rejects.toThrow( "[@octokit/webhooks-methods] secret, eventPayload & signature required", ); }); @@ -93,7 +93,7 @@ describe("verify", () => { }); it("verify(secret, eventPayload, signatureSHA256) with JSON eventPayload", async () => { - await expect(() => + await expect( // @ts-expect-error verify(secret, JSONeventPayload, signatureSHA256), ).rejects.toThrow( From 43f6e342ee4faf99cf068b6db45b0d9e66c626d0 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 19:31:55 +0200 Subject: [PATCH 04/41] more internal byte operations --- src/common/prefix-signature.ts | 28 +++++++++++++++++ src/common/verify-signature.ts | 55 +++++++++++++++++++++++++++++++--- src/index.ts | 3 +- src/methods/sign.ts | 11 +++---- src/web.ts | 2 -- 5 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 src/common/prefix-signature.ts diff --git a/src/common/prefix-signature.ts b/src/common/prefix-signature.ts new file mode 100644 index 00000000..77d4ef16 --- /dev/null +++ b/src/common/prefix-signature.ts @@ -0,0 +1,28 @@ +const hexLookUp = new Array(255).fill(0); +for (let i = 0; i < 16; i++) { + hexLookUp[i] = i.toString(16).charCodeAt(0); +} + +const hexLookUpHighByte = new Array(255).fill(0); +const hexLookUpLowByte = new Array(255).fill(0); +for (let i = 0; i < 255; i++) { + hexLookUpHighByte[i] = hexLookUp[(i & 0xf0) >> 4]; + hexLookUpLowByte[i] = hexLookUp[i & 0x0f]; +} + +export function prefixSignature(signature: Uint8Array): Uint8Array { + const prefixedSignature = new Uint8Array(71); + prefixedSignature[0] = 0x73; // 's' + prefixedSignature[1] = 0x68; // 'h' + prefixedSignature[2] = 0x61; // 'a' + prefixedSignature[3] = 0x32; // '2' + prefixedSignature[4] = 0x35; // '5' + prefixedSignature[5] = 0x36; // '6' + prefixedSignature[6] = 0x3d; // '=' + + for (let i = 0, offset = 7; i < signature.length; ++i) { + prefixedSignature[offset++] = hexLookUpHighByte[signature[i]]; + prefixedSignature[offset++] = hexLookUpLowByte[signature[i]]; + } + return prefixedSignature; +} diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts index 9c2900f8..f0a1ba0a 100644 --- a/src/common/verify-signature.ts +++ b/src/common/verify-signature.ts @@ -1,12 +1,59 @@ +import type { Signature } from "../types.js"; + const hexRE = /^sha256=[\da-fA-F]{64}$/; +export const verifySignatureString = RegExp.prototype.test.bind(hexRE) as ( + value: string, +) => value is `sha256=${string}`; /** * Verifies if a given value is a valid SHA-256 signature. * The signature must start with "sha256=" followed by a 64-character hexadecimal string. * * @param value - The value to verify. - * @returns {value is `sha256=${string}`} `true` if the value is a valid SHA-256 signature, `false` otherwise. + * @returns {value is `sha256=${string}|Uint8Array`} `true` if the value is a valid SHA-256 signature, `false` otherwise. */ -export const verifySignature = RegExp.prototype.test.bind(hexRE) as ( - value: string, -) => value is `sha256=${string}`; +export const verifySignature = ( + value: string | Uint8Array, +): value is typeof value extends string ? Signature : Uint8Array => { + if (typeof value === "string") { + return hexRE.test(value); + } else { + return verifySignatureUint8Array(value); + } +}; + +const notHexChars = new Array(256).fill(true); +for (let i = 0; i < 10; i++) { + notHexChars[i + 0x30] = false; // 0-9 +} +for (let i = 0; i < 6; i++) { + notHexChars[i + 0x61] = false; // a-f + notHexChars[i + 0x41] = false; // A-F +} + +export const verifySignatureUint8Array = ( + value: Uint8Array, +): value is Uint8Array => { + if (value.length !== 71) { + return false; + } + + if ( + value[6] !== 0x3d || // '=' character + value[0] !== 0x73 || // 's' character + value[1] !== 0x68 || // 'h' character + value[2] !== 0x61 || // 'a' character + value[3] !== 0x32 || // '2' character + value[4] !== 0x35 || // '5' character + value[5] !== 0x36 // '6' character + ) { + return false; + } + + for (let i = 7; i < 71; i++) { + if (notHexChars[value[i]]) { + return false; + } + } + return true; +}; diff --git a/src/index.ts b/src/index.ts index 66d2df0d..d00dbdf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,8 @@ import { VERSION } from "./version.js"; import { timingSafeEqual } from "./node/timing-safe-equal.js"; import { sha256 } from "./node/sha256.js"; -import { uint8ArrayToHex } from "./node/uint8array-to-hex.js"; -export const sign = signFactory({ sha256, uint8ArrayToHex }); +export const sign = signFactory({ sha256 }); export const verify = verifyFactory({ sign, timingSafeEqual, diff --git a/src/methods/sign.ts b/src/methods/sign.ts index b7df8fe4..5a870b4a 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,19 +1,16 @@ import { hmacSha256 } from "../common/hmac-sha256.js"; +import { prefixSignature } from "../common/prefix-signature.js"; import type { Signature, Signer } from "../types.js"; import { VERSION } from "../version.js"; type SignerFactoryOptions = { sha256: (data: Uint8Array) => Uint8Array | Promise; - uint8ArrayToHex: (value: Uint8Array) => string; }; -const algorithm = "sha256"; const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); -export function signFactory({ - sha256, - uint8ArrayToHex, -}: SignerFactoryOptions): Signer { +export function signFactory({ sha256 }: SignerFactoryOptions): Signer { const sign = async function sign( secret: string, payload: string, @@ -38,7 +35,7 @@ export function signFactory({ sha256, }); - return `${algorithm}=${uint8ArrayToHex(signature)}`; + return textDecoder.decode(prefixSignature(signature)) as Signature; }; sign.VERSION = VERSION; return sign; diff --git a/src/web.ts b/src/web.ts index e7acbea4..8815a6b6 100644 --- a/src/web.ts +++ b/src/web.ts @@ -5,11 +5,9 @@ import { VERSION } from "./version.js"; import { sha256 } from "./web/sha256.js"; import { timingSafeEqual } from "./web/timing-safe-equal.js"; -import { uint8ArrayToHex } from "./web/uint8array-to-hex.js"; export const sign = signFactory({ sha256, - uint8ArrayToHex, }); export const verify = verifyFactory({ sign, From 71c0019313cc4069a3e791056f9fcc72f1ae6da2 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 19:39:04 +0200 Subject: [PATCH 05/41] remove unnecessary type checks --- src/common/hmac-sha256.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/common/hmac-sha256.ts b/src/common/hmac-sha256.ts index 7d314256..9c27f0ea 100644 --- a/src/common/hmac-sha256.ts +++ b/src/common/hmac-sha256.ts @@ -13,18 +13,6 @@ export async function hmacSha256({ data, key, }: HmacSha256Options): Promise { - if (!key || !data) { - throw new TypeError( - "[@octokit/webhooks-methods] key & data required for hmacSha256()", - ); - } - - if (!(key instanceof Uint8Array) || !(data instanceof Uint8Array)) { - throw new TypeError( - "[@octokit/webhooks-methods] key & data must be Uint8Array", - ); - } - if (key.length > blockSize) { key = await sha256(key); } else if (key.length < blockSize) { From d5259dcb7df46722a5bc74ca1bd76b83eafe0e8b Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 20:19:33 +0200 Subject: [PATCH 06/41] changes push --- src/common/hmac-sha256.ts | 14 ++++++++------ src/common/prefix-signature.ts | 16 ++++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/common/hmac-sha256.ts b/src/common/hmac-sha256.ts index 9c27f0ea..eacff12a 100644 --- a/src/common/hmac-sha256.ts +++ b/src/common/hmac-sha256.ts @@ -13,22 +13,24 @@ export async function hmacSha256({ data, key, }: HmacSha256Options): Promise { - if (key.length > blockSize) { + const keyLength = key.length; + if (keyLength > blockSize) { key = await sha256(key); - } else if (key.length < blockSize) { - const zeroBuffer = new Uint8Array(blockSize).fill(0); - zeroBuffer.set(key, 0); - key = zeroBuffer; } const oKeyPad = new Uint8Array(blockSize); const iKeyPad = new Uint8Array(blockSize); - for (let i = 0; i < blockSize; i++) { + for (let i = 0; i < keyLength; i++) { oKeyPad[i] = key[i] ^ 0x5c; iKeyPad[i] = key[i] ^ 0x36; } + for (let i = keyLength; i < blockSize; i++) { + oKeyPad[i] = 0x5c; + iKeyPad[i] = 0x36; + } + const innerHash = await sha256(concatUint8Array(iKeyPad, data)); return sha256(concatUint8Array(oKeyPad, innerHash)); } diff --git a/src/common/prefix-signature.ts b/src/common/prefix-signature.ts index 77d4ef16..aa597e08 100644 --- a/src/common/prefix-signature.ts +++ b/src/common/prefix-signature.ts @@ -1,7 +1,8 @@ -const hexLookUp = new Array(255).fill(0); -for (let i = 0; i < 16; i++) { - hexLookUp[i] = i.toString(16).charCodeAt(0); -} +// 0-9, a-f hex encoding for Uint8Array signatures +const hexLookUp = [ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, + 0x64, 0x65, 0x66, +]; const hexLookUpHighByte = new Array(255).fill(0); const hexLookUpLowByte = new Array(255).fill(0); @@ -20,9 +21,12 @@ export function prefixSignature(signature: Uint8Array): Uint8Array { prefixedSignature[5] = 0x36; // '6' prefixedSignature[6] = 0x3d; // '=' - for (let i = 0, offset = 7; i < signature.length; ++i) { + let i = 0, + offset = 7; + + while (i < 32) { prefixedSignature[offset++] = hexLookUpHighByte[signature[i]]; - prefixedSignature[offset++] = hexLookUpLowByte[signature[i]]; + prefixedSignature[offset++] = hexLookUpLowByte[signature[i++]]; } return prefixedSignature; } From a47e8c29e7781193d57cb335aafba49ad5e5af3b Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 21:24:05 +0200 Subject: [PATCH 07/41] simplify --- src/common/concat-uint8array.ts | 20 -------------------- src/common/hmac-sha256.ts | 16 ++++++++-------- 2 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 src/common/concat-uint8array.ts diff --git a/src/common/concat-uint8array.ts b/src/common/concat-uint8array.ts deleted file mode 100644 index 18c3c585..00000000 --- a/src/common/concat-uint8array.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function concatUint8Array(...arrays: Uint8Array[]): Uint8Array { - const len = arrays.length; - if (len === 0) return new Uint8Array(0); - if (len === 1) return arrays[0]; - - let totalLength = 0; - // Calculate the total length of the resulting Uint8Array - for (let i = 0; i < len; i++) { - totalLength += arrays[i].length; - } - const result = new Uint8Array(totalLength); - let offset = 0; - - for (let i = 0; i < len; i++) { - result.set(arrays[i], offset); - offset += arrays[i].length; - } - - return result; -} diff --git a/src/common/hmac-sha256.ts b/src/common/hmac-sha256.ts index eacff12a..2cd090df 100644 --- a/src/common/hmac-sha256.ts +++ b/src/common/hmac-sha256.ts @@ -1,5 +1,3 @@ -import { concatUint8Array } from "./concat-uint8array.js"; - type HmacSha256Options = { key: Uint8Array; data: Uint8Array; @@ -18,19 +16,21 @@ export async function hmacSha256({ key = await sha256(key); } - const oKeyPad = new Uint8Array(blockSize); - const iKeyPad = new Uint8Array(blockSize); + const iKeyPad = new Uint8Array(blockSize + data.length); + const oKeyPad = new Uint8Array(blockSize + 32); for (let i = 0; i < keyLength; i++) { - oKeyPad[i] = key[i] ^ 0x5c; iKeyPad[i] = key[i] ^ 0x36; + oKeyPad[i] = key[i] ^ 0x5c; } for (let i = keyLength; i < blockSize; i++) { - oKeyPad[i] = 0x5c; iKeyPad[i] = 0x36; + oKeyPad[i] = 0x5c; } - const innerHash = await sha256(concatUint8Array(iKeyPad, data)); - return sha256(concatUint8Array(oKeyPad, innerHash)); + iKeyPad.set(data, blockSize); + const innerHash = await sha256(iKeyPad); + oKeyPad.set(innerHash, blockSize); + return sha256(oKeyPad); } From 52607e071343c3e17adf86dbd8acc7bf5dea596b Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 22:04:26 +0200 Subject: [PATCH 08/41] blub --- .../{prefix-signature.ts => uint8array-to-signature.ts} | 2 +- src/methods/sign.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/common/{prefix-signature.ts => uint8array-to-signature.ts} (92%) diff --git a/src/common/prefix-signature.ts b/src/common/uint8array-to-signature.ts similarity index 92% rename from src/common/prefix-signature.ts rename to src/common/uint8array-to-signature.ts index aa597e08..7b196049 100644 --- a/src/common/prefix-signature.ts +++ b/src/common/uint8array-to-signature.ts @@ -11,7 +11,7 @@ for (let i = 0; i < 255; i++) { hexLookUpLowByte[i] = hexLookUp[i & 0x0f]; } -export function prefixSignature(signature: Uint8Array): Uint8Array { +export function uint8arrayToSignature(signature: Uint8Array): Uint8Array { const prefixedSignature = new Uint8Array(71); prefixedSignature[0] = 0x73; // 's' prefixedSignature[1] = 0x68; // 'h' diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 5a870b4a..4d21e328 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,5 +1,5 @@ import { hmacSha256 } from "../common/hmac-sha256.js"; -import { prefixSignature } from "../common/prefix-signature.js"; +import { uint8arrayToSignature } from "../common/uint8array-to-signature.js"; import type { Signature, Signer } from "../types.js"; import { VERSION } from "../version.js"; @@ -35,7 +35,7 @@ export function signFactory({ sha256 }: SignerFactoryOptions): Signer { sha256, }); - return textDecoder.decode(prefixSignature(signature)) as Signature; + return textDecoder.decode(uint8arrayToSignature(signature)) as Signature; }; sign.VERSION = VERSION; return sign; From 3d56480863ec4e5820086d4fd6d7676c71806d8f Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 23:18:42 +0200 Subject: [PATCH 09/41] fix uint8array-to-hex --- src/web/uint8array-to-hex.ts | 36 +++++++++++++---------- test/benchmark-timing-safe-equal.bench.ts | 20 +++++++++++++ test/benchmark-uint8array-to-hex.bench.ts | 15 ++++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 test/benchmark-timing-safe-equal.bench.ts create mode 100644 test/benchmark-uint8array-to-hex.bench.ts diff --git a/src/web/uint8array-to-hex.ts b/src/web/uint8array-to-hex.ts index 9d401f79..5f8d484e 100644 --- a/src/web/uint8array-to-hex.ts +++ b/src/web/uint8array-to-hex.ts @@ -1,19 +1,25 @@ -const padding = "00000000"; +// 0-9, a-f hex encoding for Uint8Array signatures +const hexLookUp = [ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, + 0x64, 0x65, 0x66, +]; + +const hexLookUpHighByte = new Array(256); +const hexLookUpLowByte = new Array(256); +for (let i = 0; i < 255; i++) { + hexLookUpHighByte[i] = hexLookUp[(i & 0xf0) >> 4]; + hexLookUpLowByte[i] = hexLookUp[i & 0x0f]; +} + +const textDecoder = new TextDecoder(); export function uint8ArrayToHex(value: Uint8Array): string { - let digest = ""; - const view = new DataView(value.buffer, value.byteOffset, value.byteLength); - for (let i = 0; i < view.byteLength; i += 4) { - // We use getUint32 to reduce the number of iterations (notice the `i += 4`) - const value = view.getUint32(i); - // toString(16) will transform the integer into the corresponding hex string - // but will remove any initial "0" - const stringValue = value.toString(16); - // One Uint32 element is 4 bytes or 8 hex chars (it would also work with 4 - // chars for Uint16 and 2 chars for Uint8) - const paddedValue = (padding + stringValue).slice(-8); - digest += paddedValue; + const valueLength = value.length; + const result = new Uint8Array(valueLength * 2); + let i = 0; + while (i < valueLength) { + result[i << 1] = hexLookUpHighByte[value[i]]; + result[(i << 1) + 1] = hexLookUpLowByte[value[i++]]; } - - return digest; + return textDecoder.decode(result); } diff --git a/test/benchmark-timing-safe-equal.bench.ts b/test/benchmark-timing-safe-equal.bench.ts new file mode 100644 index 00000000..8e83266b --- /dev/null +++ b/test/benchmark-timing-safe-equal.bench.ts @@ -0,0 +1,20 @@ +import { bench, describe } from "vitest"; +import { timingSafeEqual as timingSafeEqualNode } from "../src/node/timing-safe-equal.ts"; +import { timingSafeEqual as timingSafeEqualWeb } from "../src/web/timing-safe-equal.ts"; + +describe("timingSafeEqual", () => { + const eventPayload = JSON.stringify({ + foo: "bar", + }); + + const payload = new TextEncoder().encode(eventPayload); + const payload2 = new TextEncoder().encode(eventPayload); + + bench("node", () => { + timingSafeEqualNode(payload, payload2); + }); + + bench("web", () => { + timingSafeEqualWeb(payload, payload2); + }); +}); diff --git a/test/benchmark-uint8array-to-hex.bench.ts b/test/benchmark-uint8array-to-hex.bench.ts new file mode 100644 index 00000000..2f4cf779 --- /dev/null +++ b/test/benchmark-uint8array-to-hex.bench.ts @@ -0,0 +1,15 @@ +import { bench, describe } from "vitest"; +import { uint8ArrayToHex as uint8ArrayToHexNode } from "../src/node/uint8array-to-hex.ts"; +import { uint8ArrayToHex as uint8ArrayToHexWeb } from "../src/web/uint8array-to-hex.ts"; + +describe("uint8ArrayToHex", () => { + const payload = Buffer.allocUnsafe(1e3); + + bench("node", () => { + uint8ArrayToHexNode(payload); + }); + + bench("web", () => { + uint8ArrayToHexWeb(payload); + }); +}); From b3be82c013e9d91384a9ec639336ecc4906c17ff Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 6 Jul 2025 23:31:33 +0200 Subject: [PATCH 10/41] add more tests --- test/common/uint8array-to-signature.test.ts | 18 +++++++ test/common/verify-signature.test.ts | 56 +++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 test/common/uint8array-to-signature.test.ts create mode 100644 test/common/verify-signature.test.ts diff --git a/test/common/uint8array-to-signature.test.ts b/test/common/uint8array-to-signature.test.ts new file mode 100644 index 00000000..f084f4e8 --- /dev/null +++ b/test/common/uint8array-to-signature.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { uint8arrayToSignature } from "../../src/common/uint8array-to-signature.js"; + +describe("uint8arrayToSignature", () => { + it("should return signature", () => { + const uint8array = new Uint8Array([ + 0x48, 0x64, 0xd2, 0x75, 0x99, 0x38, 0xa1, 0x54, 0x68, 0xb5, 0xdf, 0x9a, + 0xde, 0x20, 0xbf, 0x16, 0x1d, 0xa9, 0xb4, 0xf7, 0x37, 0xea, 0x61, 0x79, + 0x41, 0x42, 0xf3, 0x48, 0x42, 0x36, 0xbd, 0xa3, + ]); + const signature = uint8arrayToSignature(uint8array); + expect(signature).toStrictEqual( + new TextEncoder().encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ); + }); +}); diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts new file mode 100644 index 00000000..819a0f1f --- /dev/null +++ b/test/common/verify-signature.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { verifySignature } from "../../src/common/verify-signature.js"; + +describe("verifySignature", () => { + it("should return false for too short signature", () => { + expect( + verifySignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", + ), + ).toBe(false); + }); + + it("should return false for too long signature", () => { + expect( + verifySignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", + ), + ).toBe(false); + }); + + it("should return false for invalid algorithm", () => { + expect( + verifySignature( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ).toBe(false); + }); + + it("should return false for missing algorithm", () => { + expect( + verifySignature( + "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ).toBe(false); + }); + + it("should return false for empty signature", () => { + expect(verifySignature("")).toBe(false); + }); + + it("should return false for invalid character", () => { + expect( + verifySignature( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + ), + ).toBe(false); + }); + + it("should return true for valid signature", () => { + expect( + verifySignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ).toBe(true); + }); +}); From d15bd5f479ae14b4f738dbce40cd5c4c0b7882ff Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 00:00:20 +0200 Subject: [PATCH 11/41] improve --- src/common/verify-signature.ts | 14 ++++---- test/benchmark-verify-signature.bench.ts | 22 +++++++++++++ test/common/verify-signature.test.ts | 41 +++++++++++++++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 test/benchmark-verify-signature.bench.ts diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts index f0a1ba0a..a86bdf72 100644 --- a/src/common/verify-signature.ts +++ b/src/common/verify-signature.ts @@ -1,10 +1,10 @@ import type { Signature } from "../types.js"; -const hexRE = /^sha256=[\da-fA-F]{64}$/; +const signatureRE = /^sha256=[\da-fA-F]{64}$/; -export const verifySignatureString = RegExp.prototype.test.bind(hexRE) as ( - value: string, -) => value is `sha256=${string}`; +export const verifySignatureString = RegExp.prototype.test.bind( + signatureRE, +) as (value: string) => value is `sha256=${string}`; /** * Verifies if a given value is a valid SHA-256 signature. * The signature must start with "sha256=" followed by a 64-character hexadecimal string. @@ -16,7 +16,7 @@ export const verifySignature = ( value: string | Uint8Array, ): value is typeof value extends string ? Signature : Uint8Array => { if (typeof value === "string") { - return hexRE.test(value); + return verifySignatureString(value); } else { return verifySignatureUint8Array(value); } @@ -39,13 +39,13 @@ export const verifySignatureUint8Array = ( } if ( - value[6] !== 0x3d || // '=' character value[0] !== 0x73 || // 's' character value[1] !== 0x68 || // 'h' character value[2] !== 0x61 || // 'a' character value[3] !== 0x32 || // '2' character value[4] !== 0x35 || // '5' character - value[5] !== 0x36 // '6' character + value[5] !== 0x36 || // '6' character + value[6] !== 0x3d // '=' character ) { return false; } diff --git a/test/benchmark-verify-signature.bench.ts b/test/benchmark-verify-signature.bench.ts new file mode 100644 index 00000000..c85845d7 --- /dev/null +++ b/test/benchmark-verify-signature.bench.ts @@ -0,0 +1,22 @@ +import { bench, describe } from "vitest"; +import { + verifySignature, + verifySignatureString, + verifySignatureUint8Array, +} from "../src/common/verify-signature.js"; + +describe("verifySignature", () => { + const signature = + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; + const signatureUint8Array = new TextEncoder().encode(signature); + + bench("verifySignature", async () => { + verifySignature(signature); + }); + bench("verifySignatureString", async () => { + verifySignatureString(signature); + }); + bench("verifySignatureUint8Array", async () => { + verifySignatureUint8Array(signatureUint8Array); + }); +}); diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts index 819a0f1f..168ab223 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/verify-signature.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { verifySignature } from "../../src/common/verify-signature.js"; +const textEncoder = new TextEncoder(); describe("verifySignature", () => { it("should return false for too short signature", () => { expect( @@ -8,6 +9,13 @@ describe("verifySignature", () => { "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", ), ).toBe(false); + expect( + verifySignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", + ), + ), + ).toBe(false); }); it("should return false for too long signature", () => { @@ -16,6 +24,13 @@ describe("verifySignature", () => { "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", ), ).toBe(false); + expect( + verifySignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", + ), + ), + ).toBe(false); }); it("should return false for invalid algorithm", () => { @@ -24,18 +39,28 @@ describe("verifySignature", () => { "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(false); + expect( + verifySignature( + textEncoder.encode( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ), + ).toBe(false); }); it("should return false for missing algorithm", () => { expect( verifySignature( - "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + textEncoder.encode( + "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), ), ).toBe(false); }); it("should return false for empty signature", () => { expect(verifySignature("")).toBe(false); + expect(verifySignature(new Uint8Array())).toBe(false); }); it("should return false for invalid character", () => { @@ -44,6 +69,13 @@ describe("verifySignature", () => { "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), ).toBe(false); + expect( + verifySignature( + textEncoder.encode( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + ), + ), + ).toBe(false); }); it("should return true for valid signature", () => { @@ -52,5 +84,12 @@ describe("verifySignature", () => { "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(true); + expect( + verifySignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ), + ).toBe(true); }); }); From 3a269ca487705e8ed6bd9fd1a0c2e3d39944766a Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 00:34:41 +0200 Subject: [PATCH 12/41] more --- src/web/sha256.ts | 2 +- test/benchmark-sha256.bench.ts | 17 +++++++++++++++++ test/benchmark-sign.bench.ts | 4 ++-- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 test/benchmark-sha256.bench.ts diff --git a/src/web/sha256.ts b/src/web/sha256.ts index 77cc4aee..1f3add22 100644 --- a/src/web/sha256.ts +++ b/src/web/sha256.ts @@ -1,3 +1,3 @@ export async function sha256(input: Uint8Array): Promise { - return new Uint8Array(await crypto.subtle.digest("SHA-256", input)); + return new Uint8Array(await crypto.subtle.digest("SHA-256", input), 0, 32); } diff --git a/test/benchmark-sha256.bench.ts b/test/benchmark-sha256.bench.ts new file mode 100644 index 00000000..a550380f --- /dev/null +++ b/test/benchmark-sha256.bench.ts @@ -0,0 +1,17 @@ +import { bench, describe } from "vitest"; +import { sha256 as sha256Node } from "../src/node/sha256.ts"; +import { sha256 as sha256Web } from "../src/web/sha256.ts"; + +describe("sha256", () => { + const data = new TextEncoder().encode( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + ); + + bench("node", () => { + sha256Node(data); + }); + + bench("web", async () => { + await sha256Web(data); + }); +}); diff --git a/test/benchmark-sign.bench.ts b/test/benchmark-sign.bench.ts index 289bb7c9..73538c42 100644 --- a/test/benchmark-sign.bench.ts +++ b/test/benchmark-sign.bench.ts @@ -10,10 +10,10 @@ describe("sign", () => { const secret = "mysecret"; bench("node", async () => { - await signNode(secret, JSON.stringify(eventPayload)); + await signNode(secret, eventPayload); }); bench("web", async () => { - await signWeb(secret, JSON.stringify(eventPayload)); + await signWeb(secret, eventPayload); }); }); From 7480c6b2780ea72707a4294eff2e675a95ff0bcf Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 00:56:15 +0200 Subject: [PATCH 13/41] increase perf --- src/index.ts | 4 ++-- src/methods/sign.ts | 17 +++++++++-------- src/node/hmac-sha256.ts | 5 +++++ src/web.ts | 4 ++-- src/{common => web}/hmac-sha256.ts | 15 +++++---------- test/benchmark-hmac-sha256.bench.ts | 26 ++++++++++++++++++++++++++ 6 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 src/node/hmac-sha256.ts rename src/{common => web}/hmac-sha256.ts (70%) create mode 100644 test/benchmark-hmac-sha256.bench.ts diff --git a/src/index.ts b/src/index.ts index d00dbdf5..1def8e3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,10 @@ import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; +import { hmacSha256 } from "./node/hmac-sha256.js"; import { timingSafeEqual } from "./node/timing-safe-equal.js"; -import { sha256 } from "./node/sha256.js"; -export const sign = signFactory({ sha256 }); +export const sign = signFactory({ hmacSha256 }); export const verify = verifyFactory({ sign, timingSafeEqual, diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 4d21e328..6fc26600 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,16 +1,18 @@ -import { hmacSha256 } from "../common/hmac-sha256.js"; import { uint8arrayToSignature } from "../common/uint8array-to-signature.js"; import type { Signature, Signer } from "../types.js"; import { VERSION } from "../version.js"; type SignerFactoryOptions = { - sha256: (data: Uint8Array) => Uint8Array | Promise; + hmacSha256: ( + key: Uint8Array, + data: Uint8Array, + ) => Uint8Array | Promise; }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -export function signFactory({ sha256 }: SignerFactoryOptions): Signer { +export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { const sign = async function sign( secret: string, payload: string, @@ -29,11 +31,10 @@ export function signFactory({ sha256 }: SignerFactoryOptions): Signer { const secretBuffer = textEncoder.encode(secret); - const signature = await hmacSha256({ - data: textEncoder.encode(payload), - key: secretBuffer, - sha256, - }); + const signature = await hmacSha256( + secretBuffer, + textEncoder.encode(payload), + ); return textDecoder.decode(uint8arrayToSignature(signature)) as Signature; }; diff --git a/src/node/hmac-sha256.ts b/src/node/hmac-sha256.ts new file mode 100644 index 00000000..e655454c --- /dev/null +++ b/src/node/hmac-sha256.ts @@ -0,0 +1,5 @@ +import { createHmac } from "node:crypto"; + +export function hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array { + return createHmac("sha256", key).update(data).digest(); +} diff --git a/src/web.ts b/src/web.ts index 8815a6b6..84f73b25 100644 --- a/src/web.ts +++ b/src/web.ts @@ -3,11 +3,11 @@ import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; -import { sha256 } from "./web/sha256.js"; +import { hmacSha256 } from "./web/hmac-sha256.js"; import { timingSafeEqual } from "./web/timing-safe-equal.js"; export const sign = signFactory({ - sha256, + hmacSha256, }); export const verify = verifyFactory({ sign, diff --git a/src/common/hmac-sha256.ts b/src/web/hmac-sha256.ts similarity index 70% rename from src/common/hmac-sha256.ts rename to src/web/hmac-sha256.ts index 2cd090df..7197479e 100644 --- a/src/common/hmac-sha256.ts +++ b/src/web/hmac-sha256.ts @@ -1,16 +1,11 @@ -type HmacSha256Options = { - key: Uint8Array; - data: Uint8Array; - sha256: (data: Uint8Array) => Uint8Array | Promise; -}; +import { sha256 } from "./sha256.js"; const blockSize = 64; -export async function hmacSha256({ - sha256, - data, - key, -}: HmacSha256Options): Promise { +export async function hmacSha256( + key: Uint8Array, + data: Uint8Array, +): Promise { const keyLength = key.length; if (keyLength > blockSize) { key = await sha256(key); diff --git a/test/benchmark-hmac-sha256.bench.ts b/test/benchmark-hmac-sha256.bench.ts new file mode 100644 index 00000000..169648be --- /dev/null +++ b/test/benchmark-hmac-sha256.bench.ts @@ -0,0 +1,26 @@ +import { createHmac } from "node:crypto"; + +import { bench, describe } from "vitest"; +import { hmacSha256 as hmacSha256Node } from "../src/node/hmac-sha256.ts"; +import { hmacSha256 as hmacSha256Web } from "../src/web/hmac-sha256.ts"; + +describe("hmacSha256", () => { + const data = new TextEncoder().encode( + JSON.stringify({ + foo: "bar", + }), + ); + const key = new TextEncoder().encode("mysecret"); + + bench("node", async () => { + hmacSha256Node(key, data); + }); + + bench("hmac native", () => { + createHmac("sha256", key).update(data).digest(); + }); + + bench("sha256 - web", async () => { + await hmacSha256Web(key, data); + }); +}); From 86288b30c54df25173bb066972767c30592fdd03 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 00:59:48 +0200 Subject: [PATCH 14/41] remove redundant check --- src/node/timing-safe-equal.ts | 7 +------ src/web/timing-safe-equal.ts | 3 --- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/node/timing-safe-equal.ts b/src/node/timing-safe-equal.ts index 386d250b..8fc17a2c 100644 --- a/src/node/timing-safe-equal.ts +++ b/src/node/timing-safe-equal.ts @@ -1,8 +1,3 @@ import { timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto"; -export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { - return false; - } - return cryptoTimingSafeEqual(a, b); -} +export const timingSafeEqual = cryptoTimingSafeEqual; diff --git a/src/web/timing-safe-equal.ts b/src/web/timing-safe-equal.ts index 34a48646..f6a6da49 100644 --- a/src/web/timing-safe-equal.ts +++ b/src/web/timing-safe-equal.ts @@ -2,9 +2,6 @@ // https://stackoverflow.com/a/31096242/206879 // https://en.wikipedia.org/wiki/Timing_attack export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { - return false; - } var len = a.length; var out = 0; var i = -1; From 4a393f41e902e69b12759716bf04607809f43116 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 16:13:11 +0200 Subject: [PATCH 15/41] improve --- src/common/signature-to-uint8array.ts | 68 +++++++++++++++++++++ src/common/uint8array-to-signature.ts | 14 +++++ src/common/verify-signature.ts | 4 +- src/index.ts | 2 +- src/methods/sign.ts | 10 +-- src/methods/verify.ts | 24 +++++--- src/types.ts | 4 +- src/web.ts | 2 +- test/common/uint8array-to-signature.test.ts | 6 +- 9 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 src/common/signature-to-uint8array.ts diff --git a/src/common/signature-to-uint8array.ts b/src/common/signature-to-uint8array.ts new file mode 100644 index 00000000..4d3ea4dc --- /dev/null +++ b/src/common/signature-to-uint8array.ts @@ -0,0 +1,68 @@ +import type { SignatureString } from "../types.js"; + +const hexLookUpHighByte: Record = { + "0": 0x00, + "1": 0x10, + "2": 0x20, + "3": 0x30, + "4": 0x40, + "5": 0x50, + "6": 0x60, + "7": 0x70, + "8": 0x80, + "9": 0x90, + a: 0xa0, + b: 0xb0, + c: 0xc0, + d: 0xd0, + e: 0xe0, + f: 0xf0, + A: 0xa0, + B: 0xb0, + C: 0xc0, + D: 0xd0, + E: 0xe0, + F: 0xf0, +}; + +const hexLookUpLowByte: Record = { + "0": 0x00, + "1": 0x01, + "2": 0x02, + "3": 0x03, + "4": 0x04, + "5": 0x05, + "6": 0x06, + "7": 0x07, + "8": 0x08, + "9": 0x09, + a: 0x0a, + b: 0x0b, + c: 0x0c, + d: 0x0d, + e: 0x0e, + f: 0x0f, + A: 0x0a, + B: 0x0b, + C: 0x0c, + D: 0x0d, + E: 0x0e, + F: 0x0f, +}; + +export function signatureStringToUint8Array( + prefixedSignature: SignatureString, +): Uint8Array { + const result = new Uint8Array(32); + + let i = 0, + offset = 7; // Skip the "sha256=" prefix + + while (i < 32) { + // Each byte in the Uint8Array is represented by two hex characters + result[i++] = + hexLookUpHighByte[prefixedSignature[offset++]] + + hexLookUpLowByte[prefixedSignature[offset++]]; + } + return result; +} diff --git a/src/common/uint8array-to-signature.ts b/src/common/uint8array-to-signature.ts index 7b196049..9cb5ef00 100644 --- a/src/common/uint8array-to-signature.ts +++ b/src/common/uint8array-to-signature.ts @@ -12,6 +12,20 @@ for (let i = 0; i < 255; i++) { } export function uint8arrayToSignature(signature: Uint8Array): Uint8Array { + const prefixedSignature = new Uint8Array(64); + let i = 0, + offset = 0; + + while (i < 32) { + prefixedSignature[offset++] = hexLookUpHighByte[signature[i]]; + prefixedSignature[offset++] = hexLookUpLowByte[signature[i++]]; + } + return prefixedSignature; +} + +export function uint8arrayToPrefixedSignature( + signature: Uint8Array, +): Uint8Array { const prefixedSignature = new Uint8Array(71); prefixedSignature[0] = 0x73; // 's' prefixedSignature[1] = 0x68; // 'h' diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts index a86bdf72..ff5a4550 100644 --- a/src/common/verify-signature.ts +++ b/src/common/verify-signature.ts @@ -1,4 +1,4 @@ -import type { Signature } from "../types.js"; +import type { SignatureString } from "../types.js"; const signatureRE = /^sha256=[\da-fA-F]{64}$/; @@ -14,7 +14,7 @@ export const verifySignatureString = RegExp.prototype.test.bind( */ export const verifySignature = ( value: string | Uint8Array, -): value is typeof value extends string ? Signature : Uint8Array => { +): value is typeof value extends string ? SignatureString : Uint8Array => { if (typeof value === "string") { return verifySignatureString(value); } else { diff --git a/src/index.ts b/src/index.ts index 1def8e3d..5eafeb49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { timingSafeEqual } from "./node/timing-safe-equal.js"; export const sign = signFactory({ hmacSha256 }); export const verify = verifyFactory({ - sign, + hmacSha256, timingSafeEqual, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 6fc26600..899e546d 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,5 +1,5 @@ -import { uint8arrayToSignature } from "../common/uint8array-to-signature.js"; -import type { Signature, Signer } from "../types.js"; +import { uint8arrayToPrefixedSignature } from "../common/uint8array-to-signature.js"; +import type { SignatureString, Signer } from "../types.js"; import { VERSION } from "../version.js"; type SignerFactoryOptions = { @@ -16,7 +16,7 @@ export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { const sign = async function sign( secret: string, payload: string, - ): Promise { + ): Promise { if (!secret || !payload) { throw new TypeError( "[@octokit/webhooks-methods] secret & payload required for sign()", @@ -36,7 +36,9 @@ export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { textEncoder.encode(payload), ); - return textDecoder.decode(uint8arrayToSignature(signature)) as Signature; + return textDecoder.decode( + uint8arrayToPrefixedSignature(signature), + ) as SignatureString; }; sign.VERSION = VERSION; return sign; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index ed844af6..a75bd93e 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -1,16 +1,20 @@ -import { verifySignature } from "../common/verify-signature.js"; -import type { Signer, Verifier } from "../types.js"; +import { signatureStringToUint8Array } from "../common/signature-to-uint8array.js"; +import { verifySignatureString } from "../common/verify-signature.js"; +import type { Verifier } from "../types.js"; import { VERSION } from "../version.js"; type VerifierFactoryOptions = { - sign: Signer; + hmacSha256: ( + key: Uint8Array, + data: Uint8Array, + ) => Uint8Array | Promise; timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean; }; const textEncoder = new TextEncoder(); export function verifyFactory({ - sign, + hmacSha256, timingSafeEqual, }: VerifierFactoryOptions): Verifier { const verify: Verifier = async function verify( @@ -30,13 +34,17 @@ export function verifyFactory({ ); } - if (verifySignature(signature) === false) { + if (verifySignatureString(signature) === false) { return false; } - const signatureBuffer = textEncoder.encode(signature); - const verificationBuffer = textEncoder.encode( - await sign(secret, eventPayload), + const signatureBuffer = signatureStringToUint8Array(signature); + + const secretBuffer = textEncoder.encode(secret); + + const verificationBuffer = await hmacSha256( + secretBuffer, + textEncoder.encode(eventPayload), ); if (signatureBuffer.length !== verificationBuffer.length) { diff --git a/src/types.ts b/src/types.ts index 639f6817..29f15c07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ -export type Signature = `sha256=${string}`; +export type SignatureString = `sha256=${string}`; export type Signer = { - (secret: string, payload: string): Promise; + (secret: string, payload: string): Promise; VERSION: string; }; diff --git a/src/web.ts b/src/web.ts index 84f73b25..8371d0dd 100644 --- a/src/web.ts +++ b/src/web.ts @@ -10,7 +10,7 @@ export const sign = signFactory({ hmacSha256, }); export const verify = verifyFactory({ - sign, + hmacSha256, timingSafeEqual, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); diff --git a/test/common/uint8array-to-signature.test.ts b/test/common/uint8array-to-signature.test.ts index f084f4e8..f3015b93 100644 --- a/test/common/uint8array-to-signature.test.ts +++ b/test/common/uint8array-to-signature.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect } from "vitest"; -import { uint8arrayToSignature } from "../../src/common/uint8array-to-signature.js"; +import { uint8arrayToPrefixedSignature } from "../../src/common/uint8array-to-signature.js"; -describe("uint8arrayToSignature", () => { +describe("uint8arrayToPrefixedSignature", () => { it("should return signature", () => { const uint8array = new Uint8Array([ 0x48, 0x64, 0xd2, 0x75, 0x99, 0x38, 0xa1, 0x54, 0x68, 0xb5, 0xdf, 0x9a, 0xde, 0x20, 0xbf, 0x16, 0x1d, 0xa9, 0xb4, 0xf7, 0x37, 0xea, 0x61, 0x79, 0x41, 0x42, 0xf3, 0x48, 0x42, 0x36, 0xbd, 0xa3, ]); - const signature = uint8arrayToSignature(uint8array); + const signature = uint8arrayToPrefixedSignature(uint8array); expect(signature).toStrictEqual( new TextEncoder().encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", From 2e8fa47163d3213f080ce4f3705db23a8eb57b22 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 16:41:04 +0200 Subject: [PATCH 16/41] improve --- src/common/signature-to-uint8array.ts | 8 ++-- src/common/uint8array-to-signature.ts | 47 +++++++++++++++++++ src/common/verify-signature.ts | 6 ++- src/methods/sign.ts | 11 ++--- src/methods/verify.ts | 4 +- src/types.ts | 4 +- ...rk-uint8array-to-signature-string.bench.ts | 11 +++++ 7 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 test/benchmark-uint8array-to-signature-string.bench.ts diff --git a/src/common/signature-to-uint8array.ts b/src/common/signature-to-uint8array.ts index 4d3ea4dc..fb4577fd 100644 --- a/src/common/signature-to-uint8array.ts +++ b/src/common/signature-to-uint8array.ts @@ -1,4 +1,4 @@ -import type { SignatureString } from "../types.js"; +import type { PrefixedSignatureString } from "../types.js"; const hexLookUpHighByte: Record = { "0": 0x00, @@ -50,8 +50,8 @@ const hexLookUpLowByte: Record = { F: 0x0f, }; -export function signatureStringToUint8Array( - prefixedSignature: SignatureString, +export function prefixedSignatureStringToUint8Array( + prefixedSignature: PrefixedSignatureString, ): Uint8Array { const result = new Uint8Array(32); @@ -61,7 +61,7 @@ export function signatureStringToUint8Array( while (i < 32) { // Each byte in the Uint8Array is represented by two hex characters result[i++] = - hexLookUpHighByte[prefixedSignature[offset++]] + + hexLookUpHighByte[prefixedSignature[offset++]] | hexLookUpLowByte[prefixedSignature[offset++]]; } return result; diff --git a/src/common/uint8array-to-signature.ts b/src/common/uint8array-to-signature.ts index 9cb5ef00..91457625 100644 --- a/src/common/uint8array-to-signature.ts +++ b/src/common/uint8array-to-signature.ts @@ -1,3 +1,5 @@ +import type { PrefixedSignatureString } from "../types.js"; + // 0-9, a-f hex encoding for Uint8Array signatures const hexLookUp = [ 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, @@ -44,3 +46,48 @@ export function uint8arrayToPrefixedSignature( } return prefixedSignature; } + +const hexCharLookUp: Array = new Array(256); +for (let i = 0; i < 256; i++) { + hexCharLookUp[i] = + String.fromCharCode(hexLookUpHighByte[i]) + + String.fromCharCode(hexLookUpLowByte[i]); +} + +export function uint8arrayToPrefixedSignatureString( + signature: Uint8Array, +): PrefixedSignatureString { + return ("sha256=" + + hexCharLookUp[signature[0]] + + hexCharLookUp[signature[1]] + + hexCharLookUp[signature[2]] + + hexCharLookUp[signature[3]] + + hexCharLookUp[signature[4]] + + hexCharLookUp[signature[5]] + + hexCharLookUp[signature[6]] + + hexCharLookUp[signature[7]] + + hexCharLookUp[signature[8]] + + hexCharLookUp[signature[9]] + + hexCharLookUp[signature[10]] + + hexCharLookUp[signature[11]] + + hexCharLookUp[signature[12]] + + hexCharLookUp[signature[13]] + + hexCharLookUp[signature[14]] + + hexCharLookUp[signature[15]] + + hexCharLookUp[signature[16]] + + hexCharLookUp[signature[17]] + + hexCharLookUp[signature[18]] + + hexCharLookUp[signature[19]] + + hexCharLookUp[signature[20]] + + hexCharLookUp[signature[21]] + + hexCharLookUp[signature[22]] + + hexCharLookUp[signature[23]] + + hexCharLookUp[signature[24]] + + hexCharLookUp[signature[25]] + + hexCharLookUp[signature[26]] + + hexCharLookUp[signature[27]] + + hexCharLookUp[signature[28]] + + hexCharLookUp[signature[29]] + + hexCharLookUp[signature[30]] + + hexCharLookUp[signature[31]]) as PrefixedSignatureString; +} diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts index ff5a4550..cfdfe55d 100644 --- a/src/common/verify-signature.ts +++ b/src/common/verify-signature.ts @@ -1,4 +1,4 @@ -import type { SignatureString } from "../types.js"; +import type { PrefixedSignatureString } from "../types.js"; const signatureRE = /^sha256=[\da-fA-F]{64}$/; @@ -14,7 +14,9 @@ export const verifySignatureString = RegExp.prototype.test.bind( */ export const verifySignature = ( value: string | Uint8Array, -): value is typeof value extends string ? SignatureString : Uint8Array => { +): value is typeof value extends string + ? PrefixedSignatureString + : Uint8Array => { if (typeof value === "string") { return verifySignatureString(value); } else { diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 899e546d..725eb378 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,5 +1,5 @@ -import { uint8arrayToPrefixedSignature } from "../common/uint8array-to-signature.js"; -import type { SignatureString, Signer } from "../types.js"; +import { uint8arrayToPrefixedSignatureString } from "../common/uint8array-to-signature.js"; +import type { PrefixedSignatureString, Signer } from "../types.js"; import { VERSION } from "../version.js"; type SignerFactoryOptions = { @@ -10,13 +10,12 @@ type SignerFactoryOptions = { }; const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { const sign = async function sign( secret: string, payload: string, - ): Promise { + ): Promise { if (!secret || !payload) { throw new TypeError( "[@octokit/webhooks-methods] secret & payload required for sign()", @@ -36,9 +35,7 @@ export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { textEncoder.encode(payload), ); - return textDecoder.decode( - uint8arrayToPrefixedSignature(signature), - ) as SignatureString; + return uint8arrayToPrefixedSignatureString(signature); }; sign.VERSION = VERSION; return sign; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index a75bd93e..8e879a11 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -1,4 +1,4 @@ -import { signatureStringToUint8Array } from "../common/signature-to-uint8array.js"; +import { prefixedSignatureStringToUint8Array } from "../common/signature-to-uint8array.js"; import { verifySignatureString } from "../common/verify-signature.js"; import type { Verifier } from "../types.js"; import { VERSION } from "../version.js"; @@ -38,7 +38,7 @@ export function verifyFactory({ return false; } - const signatureBuffer = signatureStringToUint8Array(signature); + const signatureBuffer = prefixedSignatureStringToUint8Array(signature); const secretBuffer = textEncoder.encode(secret); diff --git a/src/types.ts b/src/types.ts index 29f15c07..30798402 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ -export type SignatureString = `sha256=${string}`; +export type PrefixedSignatureString = `sha256=${string}`; export type Signer = { - (secret: string, payload: string): Promise; + (secret: string, payload: string): Promise; VERSION: string; }; diff --git a/test/benchmark-uint8array-to-signature-string.bench.ts b/test/benchmark-uint8array-to-signature-string.bench.ts new file mode 100644 index 00000000..f116434f --- /dev/null +++ b/test/benchmark-uint8array-to-signature-string.bench.ts @@ -0,0 +1,11 @@ +import { randomBytes } from "node:crypto"; +import { bench, describe } from "vitest"; +import { uint8arrayToPrefixedSignatureString } from "../src/common/uint8array-to-signature.js"; + +describe("uint8arrayToPrefixedSignatureString", () => { + const payload = randomBytes(32); + + bench("uint8arrayToPrefixedSignatureString", () => { + uint8arrayToPrefixedSignatureString(payload); + }); +}); From 5ed2a61ebad7e58191c08a872b86f61d5aaf83e0 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 16:43:23 +0200 Subject: [PATCH 17/41] fix name --- src/common/verify-signature.ts | 10 +++++----- src/methods/verify.ts | 4 ++-- test/common/verify-signature.test.ts | 30 ++++++++++++++-------------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/common/verify-signature.ts b/src/common/verify-signature.ts index cfdfe55d..ea6cb3de 100644 --- a/src/common/verify-signature.ts +++ b/src/common/verify-signature.ts @@ -2,7 +2,7 @@ import type { PrefixedSignatureString } from "../types.js"; const signatureRE = /^sha256=[\da-fA-F]{64}$/; -export const verifySignatureString = RegExp.prototype.test.bind( +export const verifyPrefixedSignatureString = RegExp.prototype.test.bind( signatureRE, ) as (value: string) => value is `sha256=${string}`; /** @@ -12,15 +12,15 @@ export const verifySignatureString = RegExp.prototype.test.bind( * @param value - The value to verify. * @returns {value is `sha256=${string}|Uint8Array`} `true` if the value is a valid SHA-256 signature, `false` otherwise. */ -export const verifySignature = ( +export const verifyPrefixedSignature = ( value: string | Uint8Array, ): value is typeof value extends string ? PrefixedSignatureString : Uint8Array => { if (typeof value === "string") { - return verifySignatureString(value); + return verifyPrefixedSignatureString(value); } else { - return verifySignatureUint8Array(value); + return verifyPrefixedSignatureUint8Array(value); } }; @@ -33,7 +33,7 @@ for (let i = 0; i < 6; i++) { notHexChars[i + 0x41] = false; // A-F } -export const verifySignatureUint8Array = ( +export const verifyPrefixedSignatureUint8Array = ( value: Uint8Array, ): value is Uint8Array => { if (value.length !== 71) { diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 8e879a11..5863da08 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -1,5 +1,5 @@ import { prefixedSignatureStringToUint8Array } from "../common/signature-to-uint8array.js"; -import { verifySignatureString } from "../common/verify-signature.js"; +import { verifyPrefixedSignatureString } from "../common/verify-signature.js"; import type { Verifier } from "../types.js"; import { VERSION } from "../version.js"; @@ -34,7 +34,7 @@ export function verifyFactory({ ); } - if (verifySignatureString(signature) === false) { + if (verifyPrefixedSignatureString(signature) === false) { return false; } diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts index 168ab223..8c4b71c0 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/verify-signature.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect } from "vitest"; -import { verifySignature } from "../../src/common/verify-signature.js"; +import { verifyPrefixedSignature } from "../../src/common/verify-signature.js"; const textEncoder = new TextEncoder(); -describe("verifySignature", () => { +describe("verifyPrefixedSignature", () => { it("should return false for too short signature", () => { expect( - verifySignature( + verifyPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", ), ).toBe(false); expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", ), @@ -20,12 +20,12 @@ describe("verifySignature", () => { it("should return false for too long signature", () => { expect( - verifySignature( + verifyPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", ), ).toBe(false); expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", ), @@ -35,12 +35,12 @@ describe("verifySignature", () => { it("should return false for invalid algorithm", () => { expect( - verifySignature( + verifyPrefixedSignature( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(false); expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), @@ -50,7 +50,7 @@ describe("verifySignature", () => { it("should return false for missing algorithm", () => { expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), @@ -59,18 +59,18 @@ describe("verifySignature", () => { }); it("should return false for empty signature", () => { - expect(verifySignature("")).toBe(false); - expect(verifySignature(new Uint8Array())).toBe(false); + expect(verifyPrefixedSignature("")).toBe(false); + expect(verifyPrefixedSignature(new Uint8Array())).toBe(false); }); it("should return false for invalid character", () => { expect( - verifySignature( + verifyPrefixedSignature( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), ).toBe(false); expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), @@ -80,12 +80,12 @@ describe("verifySignature", () => { it("should return true for valid signature", () => { expect( - verifySignature( + verifyPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(true); expect( - verifySignature( + verifyPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), From 565435cbdae7845350faacaf31c60e0320d8eab0 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 16:49:25 +0200 Subject: [PATCH 18/41] generate buffers --- src/methods/sign.ts | 6 ++---- src/methods/verify.ts | 14 +++----------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 725eb378..8bb47600 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -29,11 +29,9 @@ export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { } const secretBuffer = textEncoder.encode(secret); + const payloadBuffer = textEncoder.encode(payload); - const signature = await hmacSha256( - secretBuffer, - textEncoder.encode(payload), - ); + const signature = await hmacSha256(secretBuffer, payloadBuffer); return uint8arrayToPrefixedSignatureString(signature); }; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 5863da08..fcbcd88a 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -38,18 +38,10 @@ export function verifyFactory({ return false; } - const signatureBuffer = prefixedSignatureStringToUint8Array(signature); - const secretBuffer = textEncoder.encode(secret); - - const verificationBuffer = await hmacSha256( - secretBuffer, - textEncoder.encode(eventPayload), - ); - - if (signatureBuffer.length !== verificationBuffer.length) { - return false; - } + const payloadBuffer = textEncoder.encode(eventPayload); + const verificationBuffer = await hmacSha256(secretBuffer, payloadBuffer); + const signatureBuffer = prefixedSignatureStringToUint8Array(signature); return timingSafeEqual(signatureBuffer, verificationBuffer); }; From 902cfe63f8686af384bea1debf2d53d2699b3abf Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 18:31:10 +0200 Subject: [PATCH 19/41] test web parts in node too --- package.json | 2 +- ...rk-uint8array-to-signature-string.bench.ts | 2 +- test/benchmark-verify-signature.bench.ts | 2 +- test/common/uint8array-to-signature.test.ts | 4 +- test/common/verify-signature.test.ts | 4 +- test/deno/web_test.ts | 36 --- test/sign.test.ts | 90 +++--- test/test-runner.ts | 124 ++++++++ test/verify.test.ts | 282 ++++++++++-------- 9 files changed, 334 insertions(+), 212 deletions(-) delete mode 100644 test/deno/web_test.ts create mode 100644 test/test-runner.ts diff --git a/package.json b/package.json index e543ba7e..bc347610 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:web": "npm run test:deno && npm run test:browser", "pretest:web": "npm run -s build", "test:bun": "bun test", - "test:deno": "cd test/deno && deno test", + "test:deno": "deno test --no-check --unstable-sloppy-imports", "test:browser": "node test/browser-test.js" }, "repository": "github:octokit/webhooks-methods.js", diff --git a/test/benchmark-uint8array-to-signature-string.bench.ts b/test/benchmark-uint8array-to-signature-string.bench.ts index f116434f..22bbc491 100644 --- a/test/benchmark-uint8array-to-signature-string.bench.ts +++ b/test/benchmark-uint8array-to-signature-string.bench.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { bench, describe } from "vitest"; -import { uint8arrayToPrefixedSignatureString } from "../src/common/uint8array-to-signature.js"; +import { uint8arrayToPrefixedSignatureString } from "../src/common/uint8array-to-signature.ts"; describe("uint8arrayToPrefixedSignatureString", () => { const payload = randomBytes(32); diff --git a/test/benchmark-verify-signature.bench.ts b/test/benchmark-verify-signature.bench.ts index c85845d7..1b717ac6 100644 --- a/test/benchmark-verify-signature.bench.ts +++ b/test/benchmark-verify-signature.bench.ts @@ -3,7 +3,7 @@ import { verifySignature, verifySignatureString, verifySignatureUint8Array, -} from "../src/common/verify-signature.js"; +} from "../src/common/verify-signature.ts"; describe("verifySignature", () => { const signature = diff --git a/test/common/uint8array-to-signature.test.ts b/test/common/uint8array-to-signature.test.ts index f3015b93..33183c61 100644 --- a/test/common/uint8array-to-signature.test.ts +++ b/test/common/uint8array-to-signature.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { uint8arrayToPrefixedSignature } from "../../src/common/uint8array-to-signature.js"; +import { describe, it, expect } from "../test-runner.ts"; +import { uint8arrayToPrefixedSignature } from "../../src/common/uint8array-to-signature.ts"; describe("uint8arrayToPrefixedSignature", () => { it("should return signature", () => { diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts index 8c4b71c0..35a48668 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/verify-signature.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { verifyPrefixedSignature } from "../../src/common/verify-signature.js"; +import { describe, it, expect } from "../test-runner.ts"; +import { verifyPrefixedSignature } from "../../src/common/verify-signature.ts"; const textEncoder = new TextEncoder(); describe("verifyPrefixedSignature", () => { diff --git a/test/deno/web_test.ts b/test/deno/web_test.ts deleted file mode 100644 index 68b9b022..00000000 --- a/test/deno/web_test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { sign, verify, verifyWithFallback } from "../../pkg/dist-web/index.js"; - -const assertEquals = (actual: any, expected: any) => { - if (actual !== expected) { - throw new Error(`Expected ${expected}, but got ${actual}`); - } -}; - -if ("Deno" in globalThis) { - Deno.test("sign", async () => { - const actual = await sign("secret", "data"); - const expected = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - assertEquals(actual, expected); - }); - - Deno.test("verify", async () => { - const signature = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - const actual = await verify("secret", "data", signature); - const expected = true; - assertEquals(actual, expected); - }); - - Deno.test("verify with fallback", async () => { - const signature = - "sha256=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db"; - const actual = await verifyWithFallback("foo", "data", signature, [ - "secret", - ]); - const expected = true; - assertEquals(actual, expected); - }); -} else { - console.log("This test is designed to run in Deno environment."); -} diff --git a/test/sign.test.ts b/test/sign.test.ts index f63bef7a..92fcab95 100644 --- a/test/sign.test.ts +++ b/test/sign.test.ts @@ -1,56 +1,66 @@ -import { describe, it, expect } from "vitest"; -import { sign } from "../src/index.ts"; +import { describe, it, expect } from "./test-runner.ts"; +import { sign as signNode } from "../src/index.ts"; +import { sign as signWeb } from "../src/web.ts"; const eventPayload = { foo: "bar", }; const secret = "mysecret"; -describe("sign", () => { - it("is a function", () => { - expect(sign).toBeInstanceOf(Function); - }); +[ + ["node", signNode], + ["web", signWeb], +].forEach((tuple) => { + const [environment, sign] = tuple as [string, typeof signNode]; - it("sign.VERSION is set", () => { - expect(sign.VERSION).toEqual("0.0.0-development"); - }); + describe(environment, () => { + describe("sign", () => { + it("is a function", () => { + expect(typeof sign).toBe("function"); + }); - it("throws without options throws", async () => { - // @ts-expect-error - await expect(sign()).rejects.toThrow( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - }); + it("sign.VERSION is set", () => { + expect(sign.VERSION).toBe("0.0.0-development"); + }); - it("throws without secret", async () => { - // @ts-ignore - await expect(sign(undefined, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - }); + it("throws without options throws", async () => { + // @ts-expect-error + await expect(sign()).rejects.toThrow( + "[@octokit/webhooks-methods] secret & payload required for sign()", + ); + }); - it("throws without eventPayload", async () => { - // @ts-expect-error - await expect(sign(secret)).rejects.toThrow( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - }); + it("throws without secret", async () => { + // @ts-ignore + await expect(sign(undefined, eventPayload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret & payload required for sign()", + ); + }); - describe("with eventPayload as string", () => { - describe("returns expected sha256 signature", () => { - it("sign(secret, eventPayload)", async () => { - const signature = await sign(secret, JSON.stringify(eventPayload)); - expect(signature).toBe( - "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + it("throws without eventPayload", async () => { + // @ts-expect-error + await expect(sign(secret)).rejects.toThrow( + "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); - }); - }); - it("throws with eventPayload as object", async () => { - // @ts-expect-error - await expect(sign(secret, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] payload must be a string", - ); + describe("with eventPayload as string", () => { + describe("returns expected sha256 signature", () => { + it("sign(secret, eventPayload)", async () => { + const signature = await sign(secret, JSON.stringify(eventPayload)); + expect(signature).toBe( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ); + }); + }); + }); + + it("throws with eventPayload as object", async () => { + // @ts-expect-error + await expect(sign(secret, eventPayload)).rejects.toThrow( + "[@octokit/webhooks-methods] payload must be a string", + ); + }); + }); }); }); diff --git a/test/test-runner.ts b/test/test-runner.ts new file mode 100644 index 00000000..3f638578 --- /dev/null +++ b/test/test-runner.ts @@ -0,0 +1,124 @@ +let describe: Function, + it: Function, + assert: Function, + test: Function, + expect: Function; + +if ("Bun" in globalThis) { + describe = function describe(name, fn) { + return globalThis.Bun.jest(caller()).describe(name, fn); + }; + it = function it(name, fn) { + return globalThis.Bun.jest(caller()).it(name, fn); + }; + test = function test(name, fn) { + return globalThis.Bun.jest(caller()).test(name, fn); + }; + assert = function assert(value, message) { + return globalThis.Bun.jest(caller()).expect(value, message); + }; + expect = function expect(value, message) { + return globalThis.Bun.jest(caller()).expect(value, message); + }; + /** Retrieve caller test file. */ + function caller() { + const Trace = Error; + const _ = Trace.prepareStackTrace; + Trace.prepareStackTrace = (_, stack) => stack; + const { stack } = new Error(); + Trace.prepareStackTrace = _; + const caller = (stack as unknown as CallSite[])[2]; + return caller.getFileName().replaceAll("\\", "/"); + } + + /** V8 CallSite (subset). */ + type CallSite = { getFileName: () => string }; + + /** V8 CallSite (subset). */ +} else if ("Deno" in globalThis === false && process.env.VITEST_WORKER_ID) { + const vitest = await import("vitest").then((module) => module); + describe = vitest.describe; + it = vitest.it; + test = vitest.test; + assert = vitest.assert; + expect = vitest.expect; +} else { + const nodeTest = await import("node:test"); + const nodeAssert = await import("node:assert"); + + describe = nodeTest.describe; + test = nodeTest.test; + it = nodeTest.it; + assert = nodeAssert.strict; + + // poor man's expect + expect = function expect(value: any, message: string) { + return { + toBe(expected: any) { + // @ts-ignore + nodeAssert.deepStrictEqual(value, expected, message); + }, + toStrictEqual(expected: any) { + // @ts-ignore + nodeAssert.deepStrictEqual(value, expected, message); + }, + toThrowError(expected: any) { + nodeAssert.throws(value, expected, message); + }, + rejects: { + toThrow(expected: string) { + return value + .catch((error: Error) => { + assert(error.message.includes(expected), message); + }) + .then(() => { + if (typeof value !== "object" || !value.then) { + throw new Error( + `Expected promise to reject, but it resolved with value: ${value}`, + ); + } else { + value + .catch((error: Error) => { + assert(error.message.includes(expected), message); + }) + .then(() => { + if (typeof value !== "object" || !value.then) { + throw new Error( + `Expected promise to reject, but it resolved with value: ${value}`, + ); + } + }); + } + }); + }, + toThrowError(expected: string) { + return value + .catch((error: Error) => { + assert(error.message.includes(expected), message); + }) + .then(() => { + if (typeof value !== "object" || !value.then) { + throw new Error( + `Expected promise to reject, but it resolved with value: ${value}`, + ); + } else { + value + .catch((error: Error) => { + assert(error.message.includes(expected), message); + }) + .then(() => { + if (typeof value !== "object" || !value.then) { + throw new Error( + `Expected promise to reject, but it resolved with value: ${value}`, + ); + } + }); + } + }); + }, + }, + }; + }; +} + +export { describe, it, assert, test, expect }; diff --git a/test/verify.test.ts b/test/verify.test.ts index bc881656..31235d0d 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from "vitest"; -import { verify, verifyWithFallback } from "../src/index.ts"; +import { describe, it, expect } from "./test-runner.ts"; +import { + verify as verifyNode, + verifyWithFallback as verifyWithFallbackNode, +} from "../src/index.ts"; +import { + verify as verifyWeb, + verifyWithFallback as verifyWithFallbackWeb, +} from "../src/web.ts"; import { toNormalizedJsonString } from "./common.ts"; const JSONeventPayload = { foo: "bar" }; @@ -8,132 +15,149 @@ const secret = "mysecret"; const signatureSHA256 = "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; -describe("verify", () => { - it("is a function", () => { - expect(verify).toBeInstanceOf(Function); - }); - - it("verify.VERSION is set", () => { - expect(verify.VERSION).toEqual("0.0.0-development"); - }); - - it("verify() without options throws", async () => { - // @ts-expect-error - await expect(verify()).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - }); - - it("verify(undefined, eventPayload) without secret throws", async () => { - // @ts-expect-error - await expect(verify(undefined, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - }); - - it("verify(secret) without eventPayload throws", async () => { - // @ts-expect-error - await expect(verify(secret)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - }); - - it("verify(secret, eventPayload) without options.signature throws", async () => { - // @ts-expect-error - await expect(verify(secret, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", - ); - }); - - it("verify(secret, eventPayload, signatureSHA256) returns true for correct signature", async () => { - const signatureMatches = await verify( - secret, - eventPayload, - signatureSHA256, - ); - expect(signatureMatches).toBe(true); - }); - - it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect signature", async () => { - const signatureMatches = await verify(secret, eventPayload, "foo"); - expect(signatureMatches).toBe(false); - }); - - it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect secret", async () => { - const signatureMatches = await verify("foo", eventPayload, signatureSHA256); - expect(signatureMatches).toBe(false); - }); - - it("verify(secret, eventPayload, signatureSHA256) returns true if eventPayload contains special characters (#71)", async () => { - // https://github.com/octokit/webhooks.js/issues/71 - const signatureMatchesLowerCaseSequence = await verify( - "development", - toNormalizedJsonString({ - foo: "Foo\n\u001b[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001b[0m\u001b[2K", - }), - "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", - ); - expect(signatureMatchesLowerCaseSequence).toBe(true); - const signatureMatchesUpperCaseSequence = await verify( - "development", - toNormalizedJsonString({ - foo: "Foo\n\u001B[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001B[0m\u001B[2K", - }), - "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", - ); - expect(signatureMatchesUpperCaseSequence).toBe(true); - const signatureMatchesEscapedSequence = await verify( - "development", - toNormalizedJsonString({ - foo: "\\u001b", - }), - "sha256=6f8326efbacfbd04e870cea25b5652e635be8c9807f2fd5348ef60753c9e96ed", - ); - expect(signatureMatchesEscapedSequence).toBe(true); - }); - - it("verify(secret, eventPayload, signatureSHA256) with JSON eventPayload", async () => { - await expect( - // @ts-expect-error - verify(secret, JSONeventPayload, signatureSHA256), - ).rejects.toThrow( - "[@octokit/webhooks-methods] eventPayload must be a string", - ); - }); -}); - -describe("verifyWithFallback", () => { - it("is a function", () => { - expect(verifyWithFallback).toBeInstanceOf(Function); - }); - - it("verifyWithFallback(secret, eventPayload, signatureSHA256, [bogus]) returns true", async () => { - const signatureMatches = await verifyWithFallback( - secret, - eventPayload, - signatureSHA256, - ["foo"], - ); - expect(signatureMatches).toBe(true); - }); - - it("verifyWithFallback(bogus, eventPayload, signatureSHA256, [secret]) returns true", async () => { - const signatureMatches = await verifyWithFallback( - "foo", - eventPayload, - signatureSHA256, - [secret], - ); - expect(signatureMatches).toBe(true); - }); - - it("verify(bogus, eventPayload, signatureSHA256, [bogus]) returns false", async () => { - const signatureMatches = await verifyWithFallback( - "foo", - eventPayload, - signatureSHA256, - ["foo"], - ); - expect(signatureMatches).toBe(false); +[ + ["node", verifyNode, verifyWithFallbackNode], + ["web", verifyWeb, verifyWithFallbackWeb], +].forEach((tuple) => { + const [environment, verify, verifyWithFallback] = tuple as [ + string, + typeof verifyNode, + typeof verifyWithFallbackNode, + ]; + + describe(environment, () => { + describe("verify", () => { + it("is a function", () => { + expect(typeof verify).toBe("function"); + }); + + it("verify.VERSION is set", () => { + expect(verify.VERSION).toBe("0.0.0-development"); + }); + + it("verify() without options throws", async () => { + // @ts-expect-error + await expect(verify()).rejects.toThrow( + "[@octokit/webhooks-methods] secret, eventPayload & signature required", + ); + }); + + it("verify(undefined, eventPayload) without secret throws", async () => { + // @ts-expect-error + await expect(verify(undefined, eventPayload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, eventPayload & signature required", + ); + }); + + it("verify(secret) without eventPayload throws", async () => { + // @ts-expect-error + await expect(verify(secret)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, eventPayload & signature required", + ); + }); + + it("verify(secret, eventPayload) without options.signature throws", async () => { + // @ts-expect-error + await expect(verify(secret, eventPayload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, eventPayload & signature required", + ); + }); + + it("verify(secret, eventPayload, signatureSHA256) returns true for correct signature", async () => { + const signatureMatches = await verify( + secret, + eventPayload, + signatureSHA256, + ); + expect(signatureMatches).toBe(true); + }); + + it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect signature", async () => { + const signatureMatches = await verify(secret, eventPayload, "foo"); + expect(signatureMatches).toBe(false); + }); + + it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect secret", async () => { + const signatureMatches = await verify( + "foo", + eventPayload, + signatureSHA256, + ); + expect(signatureMatches).toBe(false); + }); + + it("verify(secret, eventPayload, signatureSHA256) returns true if eventPayload contains special characters (#71)", async () => { + // https://github.com/octokit/webhooks.js/issues/71 + const signatureMatchesLowerCaseSequence = await verify( + "development", + toNormalizedJsonString({ + foo: "Foo\n\u001b[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001b[0m\u001b[2K", + }), + "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", + ); + expect(signatureMatchesLowerCaseSequence).toBe(true); + const signatureMatchesUpperCaseSequence = await verify( + "development", + toNormalizedJsonString({ + foo: "Foo\n\u001B[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001B[0m\u001B[2K", + }), + "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", + ); + expect(signatureMatchesUpperCaseSequence).toBe(true); + const signatureMatchesEscapedSequence = await verify( + "development", + toNormalizedJsonString({ + foo: "\\u001b", + }), + "sha256=6f8326efbacfbd04e870cea25b5652e635be8c9807f2fd5348ef60753c9e96ed", + ); + expect(signatureMatchesEscapedSequence).toBe(true); + }); + + it("verify(secret, eventPayload, signatureSHA256) with JSON eventPayload", async () => { + await expect( + // @ts-expect-error + verify(secret, JSONeventPayload, signatureSHA256), + ).rejects.toThrow( + "[@octokit/webhooks-methods] eventPayload must be a string", + ); + }); + }); + + describe("verifyWithFallback", () => { + it("is a function", () => { + expect(typeof verifyWithFallback).toBe("function"); + }); + + it("verifyWithFallback(secret, eventPayload, signatureSHA256, [bogus]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + secret, + eventPayload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(true); + }); + + it("verifyWithFallback(bogus, eventPayload, signatureSHA256, [secret]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + eventPayload, + signatureSHA256, + [secret], + ); + expect(signatureMatches).toBe(true); + }); + + it("verify(bogus, eventPayload, signatureSHA256, [bogus]) returns false", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + eventPayload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(false); + }); + }); }); }); From 025ef466347018d5fc0b45ac00d2710786458a0a Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 21:52:11 +0200 Subject: [PATCH 20/41] use deno v2 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d340b61..5682a6e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,9 +39,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: denoland/setup-deno@v1 + - uses: denoland/setup-deno@v2 with: - deno-version: v1.x + deno-version: v2.x - run: npm ci - run: npm run build - run: npm run test:deno From 1588c5e21d2f60bd3be8ae23ff4eb1f4a6c1ddfa Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 21:55:38 +0200 Subject: [PATCH 21/41] fix benchmarks --- test/benchmark-uint8array-to-hex.bench.ts | 3 ++- test/benchmark-verify-signature.bench.ts | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/test/benchmark-uint8array-to-hex.bench.ts b/test/benchmark-uint8array-to-hex.bench.ts index 2f4cf779..974219a4 100644 --- a/test/benchmark-uint8array-to-hex.bench.ts +++ b/test/benchmark-uint8array-to-hex.bench.ts @@ -1,9 +1,10 @@ +import { randomBytes } from "node:crypto"; import { bench, describe } from "vitest"; import { uint8ArrayToHex as uint8ArrayToHexNode } from "../src/node/uint8array-to-hex.ts"; import { uint8ArrayToHex as uint8ArrayToHexWeb } from "../src/web/uint8array-to-hex.ts"; describe("uint8ArrayToHex", () => { - const payload = Buffer.allocUnsafe(1e3); + const payload = randomBytes(1e3); bench("node", () => { uint8ArrayToHexNode(payload); diff --git a/test/benchmark-verify-signature.bench.ts b/test/benchmark-verify-signature.bench.ts index 1b717ac6..a4bd67dd 100644 --- a/test/benchmark-verify-signature.bench.ts +++ b/test/benchmark-verify-signature.bench.ts @@ -1,22 +1,22 @@ import { bench, describe } from "vitest"; import { - verifySignature, - verifySignatureString, - verifySignatureUint8Array, + verifyPrefixedSignature, + verifyPrefixedSignatureString, + verifyPrefixedSignatureUint8Array, } from "../src/common/verify-signature.ts"; -describe("verifySignature", () => { +describe("verifyPrefixedSignature", () => { const signature = "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; const signatureUint8Array = new TextEncoder().encode(signature); - bench("verifySignature", async () => { - verifySignature(signature); + bench("verifyPrefixedSignature", async () => { + verifyPrefixedSignature(signature); }); - bench("verifySignatureString", async () => { - verifySignatureString(signature); + bench("verifyPrefixedSignatureString", async () => { + verifyPrefixedSignatureString(signature); }); - bench("verifySignatureUint8Array", async () => { - verifySignatureUint8Array(signatureUint8Array); + bench("verifyPrefixedSignatureUint8Array", async () => { + verifyPrefixedSignatureUint8Array(signatureUint8Array); }); }); From f9fab010e4a27b75b2aaec804c89c30985a4bd9c Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 22:32:40 +0200 Subject: [PATCH 22/41] move benchmarks to benchmarks folder --- .../hmac-sha256.bench.ts} | 4 ++-- .../sha256.bench.ts} | 4 ++-- test/{benchmark-sign.bench.ts => benchmarks/sign.bench.ts} | 6 +++--- .../timing-safe-equal.bench.ts} | 4 ++-- .../uint8array-to-hex.bench.ts} | 4 ++-- .../uint8array-to-signature-string.bench.ts} | 2 +- .../verify-signature.bench.ts} | 2 +- .../verify.bench.ts} | 6 +++--- 8 files changed, 16 insertions(+), 16 deletions(-) rename test/{benchmark-hmac-sha256.bench.ts => benchmarks/hmac-sha256.bench.ts} (76%) rename test/{benchmark-sha256.bench.ts => benchmarks/sha256.bench.ts} (70%) rename test/{benchmark-sign.bench.ts => benchmarks/sign.bench.ts} (66%) rename test/{benchmark-timing-safe-equal.bench.ts => benchmarks/timing-safe-equal.bench.ts} (68%) rename test/{benchmark-uint8array-to-hex.bench.ts => benchmarks/uint8array-to-hex.bench.ts} (60%) rename test/{benchmark-uint8array-to-signature-string.bench.ts => benchmarks/uint8array-to-signature-string.bench.ts} (74%) rename test/{benchmark-verify-signature.bench.ts => benchmarks/verify-signature.bench.ts} (93%) rename test/{benchmark-verify.bench.ts => benchmarks/verify.bench.ts} (73%) diff --git a/test/benchmark-hmac-sha256.bench.ts b/test/benchmarks/hmac-sha256.bench.ts similarity index 76% rename from test/benchmark-hmac-sha256.bench.ts rename to test/benchmarks/hmac-sha256.bench.ts index 169648be..95162d50 100644 --- a/test/benchmark-hmac-sha256.bench.ts +++ b/test/benchmarks/hmac-sha256.bench.ts @@ -1,8 +1,8 @@ import { createHmac } from "node:crypto"; import { bench, describe } from "vitest"; -import { hmacSha256 as hmacSha256Node } from "../src/node/hmac-sha256.ts"; -import { hmacSha256 as hmacSha256Web } from "../src/web/hmac-sha256.ts"; +import { hmacSha256 as hmacSha256Node } from "../../src/node/hmac-sha256.ts"; +import { hmacSha256 as hmacSha256Web } from "../../src/web/hmac-sha256.ts"; describe("hmacSha256", () => { const data = new TextEncoder().encode( diff --git a/test/benchmark-sha256.bench.ts b/test/benchmarks/sha256.bench.ts similarity index 70% rename from test/benchmark-sha256.bench.ts rename to test/benchmarks/sha256.bench.ts index a550380f..88821d9f 100644 --- a/test/benchmark-sha256.bench.ts +++ b/test/benchmarks/sha256.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; -import { sha256 as sha256Node } from "../src/node/sha256.ts"; -import { sha256 as sha256Web } from "../src/web/sha256.ts"; +import { sha256 as sha256Node } from "../../src/node/sha256.ts"; +import { sha256 as sha256Web } from "../../src/web/sha256.ts"; describe("sha256", () => { const data = new TextEncoder().encode( diff --git a/test/benchmark-sign.bench.ts b/test/benchmarks/sign.bench.ts similarity index 66% rename from test/benchmark-sign.bench.ts rename to test/benchmarks/sign.bench.ts index 73538c42..1360fcda 100644 --- a/test/benchmark-sign.bench.ts +++ b/test/benchmarks/sign.bench.ts @@ -1,7 +1,7 @@ import { bench, describe } from "vitest"; -import { sign as signNode } from "../src/index.ts"; -import { sign as signWeb } from "../src/web.ts"; -import { toNormalizedJsonString } from "./common.ts"; +import { sign as signNode } from "../../src/index.ts"; +import { sign as signWeb } from "../../src/web.ts"; +import { toNormalizedJsonString } from "../common.ts"; describe("sign", () => { const eventPayload = toNormalizedJsonString({ diff --git a/test/benchmark-timing-safe-equal.bench.ts b/test/benchmarks/timing-safe-equal.bench.ts similarity index 68% rename from test/benchmark-timing-safe-equal.bench.ts rename to test/benchmarks/timing-safe-equal.bench.ts index 8e83266b..e28f0eb1 100644 --- a/test/benchmark-timing-safe-equal.bench.ts +++ b/test/benchmarks/timing-safe-equal.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; -import { timingSafeEqual as timingSafeEqualNode } from "../src/node/timing-safe-equal.ts"; -import { timingSafeEqual as timingSafeEqualWeb } from "../src/web/timing-safe-equal.ts"; +import { timingSafeEqual as timingSafeEqualNode } from "../../src/node/timing-safe-equal.ts"; +import { timingSafeEqual as timingSafeEqualWeb } from "../../src/web/timing-safe-equal.ts"; describe("timingSafeEqual", () => { const eventPayload = JSON.stringify({ diff --git a/test/benchmark-uint8array-to-hex.bench.ts b/test/benchmarks/uint8array-to-hex.bench.ts similarity index 60% rename from test/benchmark-uint8array-to-hex.bench.ts rename to test/benchmarks/uint8array-to-hex.bench.ts index 974219a4..08190417 100644 --- a/test/benchmark-uint8array-to-hex.bench.ts +++ b/test/benchmarks/uint8array-to-hex.bench.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { bench, describe } from "vitest"; -import { uint8ArrayToHex as uint8ArrayToHexNode } from "../src/node/uint8array-to-hex.ts"; -import { uint8ArrayToHex as uint8ArrayToHexWeb } from "../src/web/uint8array-to-hex.ts"; +import { uint8ArrayToHex as uint8ArrayToHexNode } from "../../src/node/uint8array-to-hex.ts"; +import { uint8ArrayToHex as uint8ArrayToHexWeb } from "../../src/web/uint8array-to-hex.ts"; describe("uint8ArrayToHex", () => { const payload = randomBytes(1e3); diff --git a/test/benchmark-uint8array-to-signature-string.bench.ts b/test/benchmarks/uint8array-to-signature-string.bench.ts similarity index 74% rename from test/benchmark-uint8array-to-signature-string.bench.ts rename to test/benchmarks/uint8array-to-signature-string.bench.ts index 22bbc491..b675bc0e 100644 --- a/test/benchmark-uint8array-to-signature-string.bench.ts +++ b/test/benchmarks/uint8array-to-signature-string.bench.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { bench, describe } from "vitest"; -import { uint8arrayToPrefixedSignatureString } from "../src/common/uint8array-to-signature.ts"; +import { uint8arrayToPrefixedSignatureString } from "../../src/common/uint8array-to-signature.ts"; describe("uint8arrayToPrefixedSignatureString", () => { const payload = randomBytes(32); diff --git a/test/benchmark-verify-signature.bench.ts b/test/benchmarks/verify-signature.bench.ts similarity index 93% rename from test/benchmark-verify-signature.bench.ts rename to test/benchmarks/verify-signature.bench.ts index a4bd67dd..6e48103b 100644 --- a/test/benchmark-verify-signature.bench.ts +++ b/test/benchmarks/verify-signature.bench.ts @@ -3,7 +3,7 @@ import { verifyPrefixedSignature, verifyPrefixedSignatureString, verifyPrefixedSignatureUint8Array, -} from "../src/common/verify-signature.ts"; +} from "../../src/common/verify-signature.ts"; describe("verifyPrefixedSignature", () => { const signature = diff --git a/test/benchmark-verify.bench.ts b/test/benchmarks/verify.bench.ts similarity index 73% rename from test/benchmark-verify.bench.ts rename to test/benchmarks/verify.bench.ts index 847a2ee8..20553e27 100644 --- a/test/benchmark-verify.bench.ts +++ b/test/benchmarks/verify.bench.ts @@ -1,7 +1,7 @@ import { bench, describe } from "vitest"; -import { verify as verifyNode } from "../src/index.ts"; -import { verify as verifyWeb } from "../src/web.ts"; -import { toNormalizedJsonString } from "./common.ts"; +import { verify as verifyNode } from "../../src/index.ts"; +import { verify as verifyWeb } from "../../src/web.ts"; +import { toNormalizedJsonString } from "../common.ts"; describe("verify", () => { const eventPayload = toNormalizedJsonString({ From b7ee2daa4cad66fb8479bc8659c531c776189cec Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 7 Jul 2025 23:02:09 +0200 Subject: [PATCH 23/41] split tests for verify --- test/verify-with-fallback.test.ts | 58 +++++++++++++++++++++++++++++++ test/verify.test.ts | 56 +++-------------------------- 2 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 test/verify-with-fallback.test.ts diff --git a/test/verify-with-fallback.test.ts b/test/verify-with-fallback.test.ts new file mode 100644 index 00000000..3adc773b --- /dev/null +++ b/test/verify-with-fallback.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "./test-runner.ts"; +import { verifyWithFallback as verifyWithFallbackNode } from "../src/index.ts"; +import { verifyWithFallback as verifyWithFallbackWeb } from "../src/web.ts"; +import { toNormalizedJsonString } from "./common.ts"; + +const JSONeventPayload = { foo: "bar" }; +const eventPayload = toNormalizedJsonString(JSONeventPayload); +const secret = "mysecret"; +const signatureSHA256 = + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; + +[ + ["node", verifyWithFallbackNode], + ["web", verifyWithFallbackWeb], +].forEach((tuple) => { + const [environment, verifyWithFallback] = tuple as [ + string, + typeof verifyWithFallbackNode, + ]; + + describe(environment, () => { + describe("verifyWithFallback", () => { + it("is a function", () => { + expect(typeof verifyWithFallback).toBe("function"); + }); + + it("verifyWithFallback(secret, eventPayload, signatureSHA256, [bogus]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + secret, + eventPayload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(true); + }); + + it("verifyWithFallback(bogus, eventPayload, signatureSHA256, [secret]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + eventPayload, + signatureSHA256, + [secret], + ); + expect(signatureMatches).toBe(true); + }); + + it("verify(bogus, eventPayload, signatureSHA256, [bogus]) returns false", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + eventPayload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(false); + }); + }); + }); +}); diff --git a/test/verify.test.ts b/test/verify.test.ts index 31235d0d..4f3e373c 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect } from "./test-runner.ts"; -import { - verify as verifyNode, - verifyWithFallback as verifyWithFallbackNode, -} from "../src/index.ts"; -import { - verify as verifyWeb, - verifyWithFallback as verifyWithFallbackWeb, -} from "../src/web.ts"; +import { verify as verifyNode } from "../src/index.ts"; +import { verify as verifyWeb } from "../src/web.ts"; import { toNormalizedJsonString } from "./common.ts"; const JSONeventPayload = { foo: "bar" }; @@ -16,14 +10,10 @@ const signatureSHA256 = "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; [ - ["node", verifyNode, verifyWithFallbackNode], - ["web", verifyWeb, verifyWithFallbackWeb], + ["node", verifyNode], + ["web", verifyWeb], ].forEach((tuple) => { - const [environment, verify, verifyWithFallback] = tuple as [ - string, - typeof verifyNode, - typeof verifyWithFallbackNode, - ]; + const [environment, verify] = tuple as [string, typeof verifyNode]; describe(environment, () => { describe("verify", () => { @@ -123,41 +113,5 @@ const signatureSHA256 = ); }); }); - - describe("verifyWithFallback", () => { - it("is a function", () => { - expect(typeof verifyWithFallback).toBe("function"); - }); - - it("verifyWithFallback(secret, eventPayload, signatureSHA256, [bogus]) returns true", async () => { - const signatureMatches = await verifyWithFallback( - secret, - eventPayload, - signatureSHA256, - ["foo"], - ); - expect(signatureMatches).toBe(true); - }); - - it("verifyWithFallback(bogus, eventPayload, signatureSHA256, [secret]) returns true", async () => { - const signatureMatches = await verifyWithFallback( - "foo", - eventPayload, - signatureSHA256, - [secret], - ); - expect(signatureMatches).toBe(true); - }); - - it("verify(bogus, eventPayload, signatureSHA256, [bogus]) returns false", async () => { - const signatureMatches = await verifyWithFallback( - "foo", - eventPayload, - signatureSHA256, - ["foo"], - ); - expect(signatureMatches).toBe(false); - }); - }); }); }); From cc9af33fe226bc9dc4affacc66604455a930c8a3 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 00:08:05 +0200 Subject: [PATCH 24/41] add verify with fallback benchmark --- test/benchmarks/verify-with-fallback.bench.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/benchmarks/verify-with-fallback.bench.ts diff --git a/test/benchmarks/verify-with-fallback.bench.ts b/test/benchmarks/verify-with-fallback.bench.ts new file mode 100644 index 00000000..52736a78 --- /dev/null +++ b/test/benchmarks/verify-with-fallback.bench.ts @@ -0,0 +1,34 @@ +import { bench, describe } from "vitest"; +import { verifyWithFallback as verifyWithFallbackNode } from "../../src/index.ts"; +import { verifyWithFallback as verifyWithFallbackWeb } from "../../src/web.ts"; +import { toNormalizedJsonString } from "../common.ts"; + +describe("verifyWithFallback", () => { + const eventPayload = toNormalizedJsonString({ + foo: "bar", + }); + const bogus = "foo"; + const secret = "mysecret"; + const additionalSecrets = [secret]; + + const signatureSHA256 = + "sha256=e3eccac34c43c7dc1cbb905488b1b81347fcc700a7b025697a9d07862256023f"; + + bench("node", async () => { + await verifyWithFallbackNode( + bogus, + eventPayload, + signatureSHA256, + additionalSecrets, + ); + }); + + bench("web", async () => { + await verifyWithFallbackWeb( + bogus, + eventPayload, + signatureSHA256, + additionalSecrets, + ); + }); +}); From 71da788c60ed4fa856f20a1c38f25c55ce5203ae Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 00:48:37 +0200 Subject: [PATCH 25/41] use faster Buffer.from --- src/index.ts | 4 +++- src/methods/sign.ts | 12 ++++++----- src/methods/verify.ts | 8 ++++---- src/node/string-to-uint8array.ts | 3 +++ src/web.ts | 3 +++ src/web/string-to-uint8array.ts | 4 ++++ test/benchmarks/string-to-uint8array.bench.ts | 20 +++++++++++++++++++ 7 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 src/node/string-to-uint8array.ts create mode 100644 src/web/string-to-uint8array.ts create mode 100644 test/benchmarks/string-to-uint8array.bench.ts diff --git a/src/index.ts b/src/index.ts index 5eafeb49..47e03552 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,14 @@ import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; +import { stringToUint8Array } from "./node/string-to-uint8array.js"; import { hmacSha256 } from "./node/hmac-sha256.js"; import { timingSafeEqual } from "./node/timing-safe-equal.js"; -export const sign = signFactory({ hmacSha256 }); +export const sign = signFactory({ hmacSha256, stringToUint8Array }); export const verify = verifyFactory({ hmacSha256, + stringToUint8Array, timingSafeEqual, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 8bb47600..96d97d8c 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -7,11 +7,13 @@ type SignerFactoryOptions = { key: Uint8Array, data: Uint8Array, ) => Uint8Array | Promise; + stringToUint8Array: (input: string) => Uint8Array; }; -const textEncoder = new TextEncoder(); - -export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { +export function signFactory({ + hmacSha256, + stringToUint8Array, +}: SignerFactoryOptions): Signer { const sign = async function sign( secret: string, payload: string, @@ -28,8 +30,8 @@ export function signFactory({ hmacSha256 }: SignerFactoryOptions): Signer { ); } - const secretBuffer = textEncoder.encode(secret); - const payloadBuffer = textEncoder.encode(payload); + const secretBuffer = stringToUint8Array(secret); + const payloadBuffer = stringToUint8Array(payload); const signature = await hmacSha256(secretBuffer, payloadBuffer); diff --git a/src/methods/verify.ts b/src/methods/verify.ts index fcbcd88a..e6ec04f4 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -8,13 +8,13 @@ type VerifierFactoryOptions = { key: Uint8Array, data: Uint8Array, ) => Uint8Array | Promise; + stringToUint8Array: (input: string) => Uint8Array; timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean; }; -const textEncoder = new TextEncoder(); - export function verifyFactory({ hmacSha256, + stringToUint8Array, timingSafeEqual, }: VerifierFactoryOptions): Verifier { const verify: Verifier = async function verify( @@ -38,8 +38,8 @@ export function verifyFactory({ return false; } - const secretBuffer = textEncoder.encode(secret); - const payloadBuffer = textEncoder.encode(eventPayload); + const secretBuffer = stringToUint8Array(secret); + const payloadBuffer = stringToUint8Array(eventPayload); const verificationBuffer = await hmacSha256(secretBuffer, payloadBuffer); const signatureBuffer = prefixedSignatureStringToUint8Array(signature); diff --git a/src/node/string-to-uint8array.ts b/src/node/string-to-uint8array.ts new file mode 100644 index 00000000..fc8d2697 --- /dev/null +++ b/src/node/string-to-uint8array.ts @@ -0,0 +1,3 @@ +import { Buffer } from "node:buffer"; + +export const stringToUint8Array = Buffer.from; diff --git a/src/web.ts b/src/web.ts index 8371d0dd..2460a363 100644 --- a/src/web.ts +++ b/src/web.ts @@ -4,13 +4,16 @@ import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; import { hmacSha256 } from "./web/hmac-sha256.js"; +import { stringToUint8Array } from "./web/string-to-uint8array.js"; import { timingSafeEqual } from "./web/timing-safe-equal.js"; export const sign = signFactory({ hmacSha256, + stringToUint8Array, }); export const verify = verifyFactory({ hmacSha256, + stringToUint8Array, timingSafeEqual, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); diff --git a/src/web/string-to-uint8array.ts b/src/web/string-to-uint8array.ts new file mode 100644 index 00000000..935a8257 --- /dev/null +++ b/src/web/string-to-uint8array.ts @@ -0,0 +1,4 @@ +const textEncoder = new TextEncoder(); + +export const stringToUint8Array = + TextEncoder.prototype.encode.bind(textEncoder); diff --git a/test/benchmarks/string-to-uint8array.bench.ts b/test/benchmarks/string-to-uint8array.bench.ts new file mode 100644 index 00000000..f5d65362 --- /dev/null +++ b/test/benchmarks/string-to-uint8array.bench.ts @@ -0,0 +1,20 @@ +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; +import { bench, describe } from "vitest"; + +describe("stringToUint8Array", () => { + const payload = randomBytes(1e3).toString("utf-8"); + + const bufferFrom = Buffer.from; + + bench("Buffer.from", () => { + bufferFrom(payload); + }); + + const textEncoder = new TextEncoder(); + const encode = TextEncoder.prototype.encode.bind(textEncoder); + + bench("TextEncoder", () => { + encode(payload); + }); +}); From 00d0fe04cd327931fda4672a718f44b4a8eb70b8 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 06:29:47 +0200 Subject: [PATCH 26/41] await hmacSha256 only if async --- src/common/is-async-function.ts | 7 +++++++ src/methods/sign.ts | 10 +++++++--- src/methods/verify.ts | 9 +++++++-- test/common/is-async-function.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/common/is-async-function.ts create mode 100644 test/common/is-async-function.test.ts diff --git a/src/common/is-async-function.ts b/src/common/is-async-function.ts new file mode 100644 index 00000000..76f6a1cf --- /dev/null +++ b/src/common/is-async-function.ts @@ -0,0 +1,7 @@ +const AsyncFunctionConstructor = (async () => {}).constructor; + +export function isAsyncFunction( + fn: unknown, +): fn is (...args: unknown[]) => Promise { + return fn instanceof AsyncFunctionConstructor; +} diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 96d97d8c..32658e33 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,5 +1,6 @@ -import { uint8arrayToPrefixedSignatureString } from "../common/uint8array-to-signature.js"; import type { PrefixedSignatureString, Signer } from "../types.js"; +import { isAsyncFunction } from "../common/is-async-function.js"; +import { uint8arrayToPrefixedSignatureString } from "../common/uint8array-to-signature.js"; import { VERSION } from "../version.js"; type SignerFactoryOptions = { @@ -9,11 +10,12 @@ type SignerFactoryOptions = { ) => Uint8Array | Promise; stringToUint8Array: (input: string) => Uint8Array; }; - export function signFactory({ hmacSha256, stringToUint8Array, }: SignerFactoryOptions): Signer { + const hmacSha256IsAsync = isAsyncFunction(hmacSha256); + const sign = async function sign( secret: string, payload: string, @@ -33,7 +35,9 @@ export function signFactory({ const secretBuffer = stringToUint8Array(secret); const payloadBuffer = stringToUint8Array(payload); - const signature = await hmacSha256(secretBuffer, payloadBuffer); + const signature = hmacSha256IsAsync + ? ((await hmacSha256(secretBuffer, payloadBuffer)) as Uint8Array) + : (hmacSha256(secretBuffer, payloadBuffer) as Uint8Array); return uint8arrayToPrefixedSignatureString(signature); }; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index e6ec04f4..6b4a7116 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -1,6 +1,7 @@ +import type { Verifier } from "../types.js"; +import { isAsyncFunction } from "../common/is-async-function.js"; import { prefixedSignatureStringToUint8Array } from "../common/signature-to-uint8array.js"; import { verifyPrefixedSignatureString } from "../common/verify-signature.js"; -import type { Verifier } from "../types.js"; import { VERSION } from "../version.js"; type VerifierFactoryOptions = { @@ -17,6 +18,8 @@ export function verifyFactory({ stringToUint8Array, timingSafeEqual, }: VerifierFactoryOptions): Verifier { + const hmacSha256IsAsync = isAsyncFunction(hmacSha256); + const verify: Verifier = async function verify( secret: string, eventPayload: string, @@ -40,7 +43,9 @@ export function verifyFactory({ const secretBuffer = stringToUint8Array(secret); const payloadBuffer = stringToUint8Array(eventPayload); - const verificationBuffer = await hmacSha256(secretBuffer, payloadBuffer); + const verificationBuffer = hmacSha256IsAsync + ? ((await hmacSha256(secretBuffer, payloadBuffer)) as Uint8Array) + : (hmacSha256(secretBuffer, payloadBuffer) as Uint8Array); const signatureBuffer = prefixedSignatureStringToUint8Array(signature); return timingSafeEqual(signatureBuffer, verificationBuffer); diff --git a/test/common/is-async-function.test.ts b/test/common/is-async-function.test.ts new file mode 100644 index 00000000..5036b3f9 --- /dev/null +++ b/test/common/is-async-function.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "../test-runner.ts"; +import { isAsyncFunction } from "../../src/common/is-async-function.ts"; + +describe("isAsyncFunction", () => { + it("should return true for async function", () => { + expect(isAsyncFunction(async () => {})).toBe(true); + expect(isAsyncFunction(async function () {})).toBe(true); + }); + + it("should return false for regular function", () => { + expect(isAsyncFunction(() => {})).toBe(false); + expect(isAsyncFunction(function () {})).toBe(false); + }); + + it("should return false for non-function values", () => { + expect(isAsyncFunction(null)).toBe(false); + expect(isAsyncFunction(undefined)).toBe(false); + expect(isAsyncFunction(42)).toBe(false); + expect(isAsyncFunction("string")).toBe(false); + expect(isAsyncFunction({})).toBe(false); + expect(isAsyncFunction([])).toBe(false); + }); +}); From f869698d66ce1f76c8e9319324e40a0f9249a2a4 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 10:09:43 +0200 Subject: [PATCH 27/41] rename eventPayload to payload --- src/methods/verify.ts | 8 ++++---- src/types.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 6b4a7116..bd07e515 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -22,16 +22,16 @@ export function verifyFactory({ const verify: Verifier = async function verify( secret: string, - eventPayload: string, + payload: string, signature: string, ): Promise { - if (!secret || !eventPayload || !signature) { + if (!secret || !payload || !signature) { throw new TypeError( "[@octokit/webhooks-methods] secret, eventPayload & signature required", ); } - if (typeof eventPayload !== "string") { + if (typeof payload !== "string") { throw new TypeError( "[@octokit/webhooks-methods] eventPayload must be a string", ); @@ -42,7 +42,7 @@ export function verifyFactory({ } const secretBuffer = stringToUint8Array(secret); - const payloadBuffer = stringToUint8Array(eventPayload); + const payloadBuffer = stringToUint8Array(payload); const verificationBuffer = hmacSha256IsAsync ? ((await hmacSha256(secretBuffer, payloadBuffer)) as Uint8Array) : (hmacSha256(secretBuffer, payloadBuffer) as Uint8Array); diff --git a/src/types.ts b/src/types.ts index 30798402..5cd338d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,7 @@ export type Signer = { }; export type Verifier = { - (secret: string, eventPayload: string, signature: string): Promise; + (secret: string, payload: string, signature: string): Promise; VERSION: string; }; From b88e164c0d8439dd7b97d350dd8fe138f69c0f82 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 13:17:31 +0200 Subject: [PATCH 28/41] improve testing --- package.json | 9 +++------ src/methods/sign.ts | 7 ++----- src/methods/verify.ts | 6 +----- test/benchmarks/sign.bench.ts | 6 +++--- test/benchmarks/verify.bench.ts | 10 +++++----- test/browser-test.js | 10 ++++------ 6 files changed, 18 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index bc347610..7a761a53 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,11 @@ "build": "node scripts/build.mjs && tsc -p tsconfig.json", "lint": "prettier --check '{src,test,scripts}/**/*' README.md package.json", "lint:fix": "prettier --write '{src,test,scripts}/**/*' README.md package.json", - "pretest": "npm run -s lint", - "test": "npm run -s test:node && npm run -s test:web", - "test:node": "vitest run --coverage", - "test:web": "npm run test:deno && npm run test:browser", - "pretest:web": "npm run -s build", + "test": "npm run -s test:node && npm run -s test:deno && npm run test:bun && npm run -s test:browser", + "test:browser": "npm run -s build && node test/browser-test.js", "test:bun": "bun test", "test:deno": "deno test --no-check --unstable-sloppy-imports", - "test:browser": "node test/browser-test.js" + "test:node": "vitest run --coverage" }, "repository": "github:octokit/webhooks-methods.js", "keywords": [ diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 32658e33..1d82fd0d 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -1,4 +1,4 @@ -import type { PrefixedSignatureString, Signer } from "../types.js"; +import type { Signer } from "../types.js"; import { isAsyncFunction } from "../common/is-async-function.js"; import { uint8arrayToPrefixedSignatureString } from "../common/uint8array-to-signature.js"; import { VERSION } from "../version.js"; @@ -16,10 +16,7 @@ export function signFactory({ }: SignerFactoryOptions): Signer { const hmacSha256IsAsync = isAsyncFunction(hmacSha256); - const sign = async function sign( - secret: string, - payload: string, - ): Promise { + const sign: Signer = async function sign(secret, payload) { if (!secret || !payload) { throw new TypeError( "[@octokit/webhooks-methods] secret & payload required for sign()", diff --git a/src/methods/verify.ts b/src/methods/verify.ts index bd07e515..19b5ee14 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -20,11 +20,7 @@ export function verifyFactory({ }: VerifierFactoryOptions): Verifier { const hmacSha256IsAsync = isAsyncFunction(hmacSha256); - const verify: Verifier = async function verify( - secret: string, - payload: string, - signature: string, - ): Promise { + const verify: Verifier = async function verify(secret, payload, signature) { if (!secret || !payload || !signature) { throw new TypeError( "[@octokit/webhooks-methods] secret, eventPayload & signature required", diff --git a/test/benchmarks/sign.bench.ts b/test/benchmarks/sign.bench.ts index 1360fcda..c9174f76 100644 --- a/test/benchmarks/sign.bench.ts +++ b/test/benchmarks/sign.bench.ts @@ -4,16 +4,16 @@ import { sign as signWeb } from "../../src/web.ts"; import { toNormalizedJsonString } from "../common.ts"; describe("sign", () => { - const eventPayload = toNormalizedJsonString({ + const payload = toNormalizedJsonString({ foo: "bar", }); const secret = "mysecret"; bench("node", async () => { - await signNode(secret, eventPayload); + await signNode(secret, payload); }); bench("web", async () => { - await signWeb(secret, eventPayload); + await signWeb(secret, payload); }); }); diff --git a/test/benchmarks/verify.bench.ts b/test/benchmarks/verify.bench.ts index 20553e27..ff0cec7a 100644 --- a/test/benchmarks/verify.bench.ts +++ b/test/benchmarks/verify.bench.ts @@ -3,20 +3,20 @@ import { verify as verifyNode } from "../../src/index.ts"; import { verify as verifyWeb } from "../../src/web.ts"; import { toNormalizedJsonString } from "../common.ts"; -describe("verify", () => { - const eventPayload = toNormalizedJsonString({ +describe("verify", async () => { + const payload = toNormalizedJsonString({ foo: "bar", }); const secret = "mysecret"; const signatureSHA256 = - "sha256=e3eccac34c43c7dc1cbb905488b1b81347fcc700a7b025697a9d07862256023f"; + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; bench("node", async () => { - await verifyNode(secret, eventPayload, signatureSHA256); + await verifyNode(secret, payload, signatureSHA256); }); bench("web", async () => { - await verifyWeb(secret, eventPayload, signatureSHA256); + await verifyWeb(secret, payload, signatureSHA256); }); }); diff --git a/test/browser-test.js b/test/browser-test.js index c9ab4160..36f00c46 100644 --- a/test/browser-test.js +++ b/test/browser-test.js @@ -1,11 +1,13 @@ import { strictEqual } from "node:assert"; - import { readFile } from "node:fs/promises"; + import puppeteer from "puppeteer"; runTests(); async function runTests() { + console.log("Running browser tests..."); + const script = await readFile("pkg/dist-web/index.js", "utf-8"); const browser = await puppeteer.launch(); const page = await browser.newPage(); @@ -25,11 +27,7 @@ async function runTests() { const [signature, verified] = await page.evaluate(async function () { const signature = await sign("secret", "data"); - console.log(signature); - const verified = await verify("secret", "data", signature); - console.log(verified); - return [signature, verified]; }); @@ -41,5 +39,5 @@ async function runTests() { await browser.close(); - console.log("All tests passed."); + console.log("All browser tests passed."); } From be9b394dc53e1ac057c813a7f786a748d3f9ac41 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 13:21:39 +0200 Subject: [PATCH 29/41] add bun to test --- .github/workflows/test.yml | 17 ++++++++++++----- package.json | 3 ++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5682a6e6..a39166fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,16 @@ jobs: - name: Disable AppArmor # https://pptr.dev/troubleshooting#issues-with-apparmor-on-ubuntu run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - run: npm ci - - run: npm run build - run: npm run test:browser + bun: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun test + deno: runs-on: ubuntu-latest steps: @@ -43,7 +50,6 @@ jobs: with: deno-version: v2.x - run: npm ci - - run: npm run build - run: npm run test:deno node: @@ -69,13 +75,14 @@ jobs: runs-on: ubuntu-latest needs: - lint - - node - - deno - browser + - bun + - deno + - node steps: - run: exit 1 if: - ${{ needs.lint.result != 'success' || needs.node.result != 'success' || + ${{ needs.lint.result != 'success' || needs.node.result != 'success' || needs.bun.result != 'success' || needs.browser.result != 'success' || needs.deno.result != 'success' }} - run: echo ok if: ${{ always() }} diff --git a/package.json b/package.json index 7a761a53..9d2a2e10 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint": "prettier --check '{src,test,scripts}/**/*' README.md package.json", "lint:fix": "prettier --write '{src,test,scripts}/**/*' README.md package.json", "test": "npm run -s test:node && npm run -s test:deno && npm run test:bun && npm run -s test:browser", - "test:browser": "npm run -s build && node test/browser-test.js", + "pretest:browser": "npm run build", + "test:browser": "node test/browser-test.js", "test:bun": "bun test", "test:deno": "deno test --no-check --unstable-sloppy-imports", "test:node": "vitest run --coverage" From 294c4b168f542441642ac2c42006a0773f4f002c Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 13:24:55 +0200 Subject: [PATCH 30/41] fix coverage --- test/common/verify-signature.test.ts | 2 +- vite.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts index 35a48668..b34f448d 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/verify-signature.test.ts @@ -72,7 +72,7 @@ describe("verifyPrefixedSignature", () => { expect( verifyPrefixedSignature( textEncoder.encode( - "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), ), ).toBe(false); diff --git a/vite.config.js b/vite.config.js index 6bc6ed2a..516c9dfe 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,7 +3,7 @@ import { defineConfig } from "vite"; export default defineConfig({ test: { coverage: { - include: ["test/**/*.ts"], + include: ["src/**/*.ts"], reporter: ["html"], thresholds: { 100: true, From c777750b236f0f427c0ae921072ced8c1feb3692 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 13:58:15 +0200 Subject: [PATCH 31/41] remove unused file --- src/node/uint8array-to-hex.ts | 5 ----- src/web/uint8array-to-hex.ts | 25 ------------------------- 2 files changed, 30 deletions(-) delete mode 100644 src/node/uint8array-to-hex.ts delete mode 100644 src/web/uint8array-to-hex.ts diff --git a/src/node/uint8array-to-hex.ts b/src/node/uint8array-to-hex.ts deleted file mode 100644 index e5cc29b9..00000000 --- a/src/node/uint8array-to-hex.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Buffer } from "node:buffer"; - -export function uint8ArrayToHex(value: ArrayBufferLike): string { - return Buffer.from(value).toString("hex"); -} diff --git a/src/web/uint8array-to-hex.ts b/src/web/uint8array-to-hex.ts deleted file mode 100644 index 5f8d484e..00000000 --- a/src/web/uint8array-to-hex.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 0-9, a-f hex encoding for Uint8Array signatures -const hexLookUp = [ - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, - 0x64, 0x65, 0x66, -]; - -const hexLookUpHighByte = new Array(256); -const hexLookUpLowByte = new Array(256); -for (let i = 0; i < 255; i++) { - hexLookUpHighByte[i] = hexLookUp[(i & 0xf0) >> 4]; - hexLookUpLowByte[i] = hexLookUp[i & 0x0f]; -} - -const textDecoder = new TextDecoder(); - -export function uint8ArrayToHex(value: Uint8Array): string { - const valueLength = value.length; - const result = new Uint8Array(valueLength * 2); - let i = 0; - while (i < valueLength) { - result[i << 1] = hexLookUpHighByte[value[i]]; - result[(i << 1) + 1] = hexLookUpLowByte[value[i++]]; - } - return textDecoder.decode(result); -} From 99e3a20abc8c2d4e1208f516539d1c9ff4358a77 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 14:13:41 +0200 Subject: [PATCH 32/41] remove sha256 --- src/node/sha256.ts | 5 ----- src/web/hmac-sha256.ts | 12 +++++++----- src/web/sha256.ts | 3 --- 3 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 src/node/sha256.ts delete mode 100644 src/web/sha256.ts diff --git a/src/node/sha256.ts b/src/node/sha256.ts deleted file mode 100644 index a818c992..00000000 --- a/src/node/sha256.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hash } from "node:crypto"; - -export function sha256(input: Uint8Array): Uint8Array { - return hash("sha256", input, "buffer"); -} diff --git a/src/web/hmac-sha256.ts b/src/web/hmac-sha256.ts index 7197479e..c564f84f 100644 --- a/src/web/hmac-sha256.ts +++ b/src/web/hmac-sha256.ts @@ -1,5 +1,3 @@ -import { sha256 } from "./sha256.js"; - const blockSize = 64; export async function hmacSha256( @@ -8,7 +6,7 @@ export async function hmacSha256( ): Promise { const keyLength = key.length; if (keyLength > blockSize) { - key = await sha256(key); + key = new Uint8Array(await crypto.subtle.digest("SHA-256", key), 0, 32); } const iKeyPad = new Uint8Array(blockSize + data.length); @@ -25,7 +23,11 @@ export async function hmacSha256( } iKeyPad.set(data, blockSize); - const innerHash = await sha256(iKeyPad); + const innerHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", iKeyPad), + 0, + 32, + ); oKeyPad.set(innerHash, blockSize); - return sha256(oKeyPad); + return new Uint8Array(await crypto.subtle.digest("SHA-256", oKeyPad), 0, 32); } diff --git a/src/web/sha256.ts b/src/web/sha256.ts deleted file mode 100644 index 1f3add22..00000000 --- a/src/web/sha256.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function sha256(input: Uint8Array): Promise { - return new Uint8Array(await crypto.subtle.digest("SHA-256", input), 0, 32); -} From 370de553b9ff0342c9ee5d94f27e56d8c33d414c Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 14:35:19 +0200 Subject: [PATCH 33/41] improve coverage --- src/common/is-async-function.ts | 1 + src/common/uint8array-to-signature.ts | 12 ------------ test/common/verify-signature.test.ts | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/common/is-async-function.ts b/src/common/is-async-function.ts index 76f6a1cf..5ca601ae 100644 --- a/src/common/is-async-function.ts +++ b/src/common/is-async-function.ts @@ -1,3 +1,4 @@ +/* c8 ignore next */ const AsyncFunctionConstructor = (async () => {}).constructor; export function isAsyncFunction( diff --git a/src/common/uint8array-to-signature.ts b/src/common/uint8array-to-signature.ts index 91457625..deffc4a3 100644 --- a/src/common/uint8array-to-signature.ts +++ b/src/common/uint8array-to-signature.ts @@ -13,18 +13,6 @@ for (let i = 0; i < 255; i++) { hexLookUpLowByte[i] = hexLookUp[i & 0x0f]; } -export function uint8arrayToSignature(signature: Uint8Array): Uint8Array { - const prefixedSignature = new Uint8Array(64); - let i = 0, - offset = 0; - - while (i < 32) { - prefixedSignature[offset++] = hexLookUpHighByte[signature[i]]; - prefixedSignature[offset++] = hexLookUpLowByte[signature[i++]]; - } - return prefixedSignature; -} - export function uint8arrayToPrefixedSignature( signature: Uint8Array, ): Uint8Array { diff --git a/test/common/verify-signature.test.ts b/test/common/verify-signature.test.ts index b34f448d..e1760326 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/verify-signature.test.ts @@ -66,7 +66,7 @@ describe("verifyPrefixedSignature", () => { it("should return false for invalid character", () => { expect( verifyPrefixedSignature( - "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), ).toBe(false); expect( From 724b570735ce6e6970e2f32c2f9f68b08a167ec4 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 14:36:45 +0200 Subject: [PATCH 34/41] fix --- test/benchmarks/hmac-sha256.bench.ts | 6 ------ test/benchmarks/sha256.bench.ts | 17 ----------------- 2 files changed, 23 deletions(-) delete mode 100644 test/benchmarks/sha256.bench.ts diff --git a/test/benchmarks/hmac-sha256.bench.ts b/test/benchmarks/hmac-sha256.bench.ts index 95162d50..0fe464d2 100644 --- a/test/benchmarks/hmac-sha256.bench.ts +++ b/test/benchmarks/hmac-sha256.bench.ts @@ -1,5 +1,3 @@ -import { createHmac } from "node:crypto"; - import { bench, describe } from "vitest"; import { hmacSha256 as hmacSha256Node } from "../../src/node/hmac-sha256.ts"; import { hmacSha256 as hmacSha256Web } from "../../src/web/hmac-sha256.ts"; @@ -16,10 +14,6 @@ describe("hmacSha256", () => { hmacSha256Node(key, data); }); - bench("hmac native", () => { - createHmac("sha256", key).update(data).digest(); - }); - bench("sha256 - web", async () => { await hmacSha256Web(key, data); }); diff --git a/test/benchmarks/sha256.bench.ts b/test/benchmarks/sha256.bench.ts deleted file mode 100644 index 88821d9f..00000000 --- a/test/benchmarks/sha256.bench.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { bench, describe } from "vitest"; -import { sha256 as sha256Node } from "../../src/node/sha256.ts"; -import { sha256 as sha256Web } from "../../src/web/sha256.ts"; - -describe("sha256", () => { - const data = new TextEncoder().encode( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - ); - - bench("node", () => { - sha256Node(data); - }); - - bench("web", async () => { - await sha256Web(data); - }); -}); From d11a68dfbb0af25ae20bcf8f10aebad1ada630cd Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 15:12:46 +0200 Subject: [PATCH 35/41] use again webcrypto for web --- src/web/hmac-sha256.ts | 43 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/web/hmac-sha256.ts b/src/web/hmac-sha256.ts index c564f84f..f1be3473 100644 --- a/src/web/hmac-sha256.ts +++ b/src/web/hmac-sha256.ts @@ -1,33 +1,22 @@ -const blockSize = 64; - export async function hmacSha256( key: Uint8Array, data: Uint8Array, ): Promise { - const keyLength = key.length; - if (keyLength > blockSize) { - key = new Uint8Array(await crypto.subtle.digest("SHA-256", key), 0, 32); - } - - const iKeyPad = new Uint8Array(blockSize + data.length); - const oKeyPad = new Uint8Array(blockSize + 32); - - for (let i = 0; i < keyLength; i++) { - iKeyPad[i] = key[i] ^ 0x36; - oKeyPad[i] = key[i] ^ 0x5c; - } - - for (let i = keyLength; i < blockSize; i++) { - iKeyPad[i] = 0x36; - oKeyPad[i] = 0x5c; - } - - iKeyPad.set(data, blockSize); - const innerHash = new Uint8Array( - await crypto.subtle.digest("SHA-256", iKeyPad), - 0, - 32, + const importedKey = await crypto.subtle.importKey( + "raw", // raw format of the key - should be Uint8Array + key, // the key to import + { + // algorithm details + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, // export = false + ["sign", "verify"], // what this key can do ); - oKeyPad.set(innerHash, blockSize); - return new Uint8Array(await crypto.subtle.digest("SHA-256", oKeyPad), 0, 32); + const signature = await crypto.subtle.sign( + "HMAC", + importedKey, // the key to use for signing + data, + ); + return new Uint8Array(signature); } From 5358c79a9b4ae31ea3dea942a5301e7158f40e90 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 15:13:55 +0200 Subject: [PATCH 36/41] remove dead code --- test/benchmarks/uint8array-to-hex.bench.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 test/benchmarks/uint8array-to-hex.bench.ts diff --git a/test/benchmarks/uint8array-to-hex.bench.ts b/test/benchmarks/uint8array-to-hex.bench.ts deleted file mode 100644 index 08190417..00000000 --- a/test/benchmarks/uint8array-to-hex.bench.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { bench, describe } from "vitest"; -import { uint8ArrayToHex as uint8ArrayToHexNode } from "../../src/node/uint8array-to-hex.ts"; -import { uint8ArrayToHex as uint8ArrayToHexWeb } from "../../src/web/uint8array-to-hex.ts"; - -describe("uint8ArrayToHex", () => { - const payload = randomBytes(1e3); - - bench("node", () => { - uint8ArrayToHexNode(payload); - }); - - bench("web", () => { - uint8ArrayToHexWeb(payload); - }); -}); From 3cb3a063e781fac2d769faf3495142ca26771b2a Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 23:01:58 +0200 Subject: [PATCH 37/41] improve --- src/index.ts | 8 +++++++- src/methods/sign.ts | 16 +++++++++------- src/methods/verify.ts | 16 +++++++++------- src/node/create-key-from-secret.ts | 3 +++ src/web.ts | 3 +++ src/web/create-key-from-secret.ts | 17 +++++++++++++++++ src/web/hmac-sha256.ts | 15 ++------------- 7 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 src/node/create-key-from-secret.ts create mode 100644 src/web/create-key-from-secret.ts diff --git a/src/index.ts b/src/index.ts index 47e03552..95e502b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,18 @@ import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; +import { createKeyFromSecret } from "./node/create-key-from-secret.js"; import { stringToUint8Array } from "./node/string-to-uint8array.js"; import { hmacSha256 } from "./node/hmac-sha256.js"; import { timingSafeEqual } from "./node/timing-safe-equal.js"; -export const sign = signFactory({ hmacSha256, stringToUint8Array }); +export const sign = signFactory({ + createKeyFromSecret, + hmacSha256, + stringToUint8Array, +}); export const verify = verifyFactory({ + createKeyFromSecret, hmacSha256, stringToUint8Array, timingSafeEqual, diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 1d82fd0d..95d001eb 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -4,16 +4,16 @@ import { uint8arrayToPrefixedSignatureString } from "../common/uint8array-to-sig import { VERSION } from "../version.js"; type SignerFactoryOptions = { - hmacSha256: ( - key: Uint8Array, - data: Uint8Array, - ) => Uint8Array | Promise; + createKeyFromSecret: (secret: string) => any | Promise; + hmacSha256: (key: any, data: Uint8Array) => Uint8Array | Promise; stringToUint8Array: (input: string) => Uint8Array; }; export function signFactory({ + createKeyFromSecret, hmacSha256, stringToUint8Array, }: SignerFactoryOptions): Signer { + const createKeyFromSecretIsAsync = isAsyncFunction(createKeyFromSecret); const hmacSha256IsAsync = isAsyncFunction(hmacSha256); const sign: Signer = async function sign(secret, payload) { @@ -29,12 +29,14 @@ export function signFactory({ ); } - const secretBuffer = stringToUint8Array(secret); + const key = createKeyFromSecretIsAsync + ? await createKeyFromSecret(secret) + : createKeyFromSecret(secret); const payloadBuffer = stringToUint8Array(payload); const signature = hmacSha256IsAsync - ? ((await hmacSha256(secretBuffer, payloadBuffer)) as Uint8Array) - : (hmacSha256(secretBuffer, payloadBuffer) as Uint8Array); + ? ((await hmacSha256(key, payloadBuffer)) as Uint8Array) + : (hmacSha256(key, payloadBuffer) as Uint8Array); return uint8arrayToPrefixedSignatureString(signature); }; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 19b5ee14..31c941ed 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -5,19 +5,19 @@ import { verifyPrefixedSignatureString } from "../common/verify-signature.js"; import { VERSION } from "../version.js"; type VerifierFactoryOptions = { - hmacSha256: ( - key: Uint8Array, - data: Uint8Array, - ) => Uint8Array | Promise; + createKeyFromSecret: (secret: string) => any | Promise; + hmacSha256: (key: any, data: Uint8Array) => Uint8Array | Promise; stringToUint8Array: (input: string) => Uint8Array; timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean; }; export function verifyFactory({ + createKeyFromSecret, hmacSha256, stringToUint8Array, timingSafeEqual, }: VerifierFactoryOptions): Verifier { + const createKeyFromSecretIsAsync = isAsyncFunction(createKeyFromSecret); const hmacSha256IsAsync = isAsyncFunction(hmacSha256); const verify: Verifier = async function verify(secret, payload, signature) { @@ -37,11 +37,13 @@ export function verifyFactory({ return false; } - const secretBuffer = stringToUint8Array(secret); + const key = createKeyFromSecretIsAsync + ? await createKeyFromSecret(secret) + : createKeyFromSecret(secret); const payloadBuffer = stringToUint8Array(payload); const verificationBuffer = hmacSha256IsAsync - ? ((await hmacSha256(secretBuffer, payloadBuffer)) as Uint8Array) - : (hmacSha256(secretBuffer, payloadBuffer) as Uint8Array); + ? ((await hmacSha256(key, payloadBuffer)) as Uint8Array) + : (hmacSha256(key, payloadBuffer) as Uint8Array); const signatureBuffer = prefixedSignatureStringToUint8Array(signature); return timingSafeEqual(signatureBuffer, verificationBuffer); diff --git a/src/node/create-key-from-secret.ts b/src/node/create-key-from-secret.ts new file mode 100644 index 00000000..f211827d --- /dev/null +++ b/src/node/create-key-from-secret.ts @@ -0,0 +1,3 @@ +import { stringToUint8Array } from "./string-to-uint8array.js"; + +export const createKeyFromSecret = stringToUint8Array; diff --git a/src/web.ts b/src/web.ts index 2460a363..4e57eb7d 100644 --- a/src/web.ts +++ b/src/web.ts @@ -3,15 +3,18 @@ import { verifyWithFallbackFactory } from "./methods/verify-with-fallback.js"; import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; +import { createKeyFromSecret } from "./web/create-key-from-secret.js"; import { hmacSha256 } from "./web/hmac-sha256.js"; import { stringToUint8Array } from "./web/string-to-uint8array.js"; import { timingSafeEqual } from "./web/timing-safe-equal.js"; export const sign = signFactory({ + createKeyFromSecret, hmacSha256, stringToUint8Array, }); export const verify = verifyFactory({ + createKeyFromSecret, hmacSha256, stringToUint8Array, timingSafeEqual, diff --git a/src/web/create-key-from-secret.ts b/src/web/create-key-from-secret.ts new file mode 100644 index 00000000..4fe2d596 --- /dev/null +++ b/src/web/create-key-from-secret.ts @@ -0,0 +1,17 @@ +import { stringToUint8Array } from "./string-to-uint8array.js"; + +type CryptoKey = Awaited>; + +export async function createKeyFromSecret(secret: string): Promise { + return await crypto.subtle.importKey( + "raw", // raw format of the key - should be Uint8Array + stringToUint8Array(secret), // the key to import + { + // algorithm details + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, // export = false + ["sign", "verify"], // what this key can do + ); +} diff --git a/src/web/hmac-sha256.ts b/src/web/hmac-sha256.ts index f1be3473..2b7e5ff6 100644 --- a/src/web/hmac-sha256.ts +++ b/src/web/hmac-sha256.ts @@ -1,21 +1,10 @@ export async function hmacSha256( - key: Uint8Array, + key: Awaited>, data: Uint8Array, ): Promise { - const importedKey = await crypto.subtle.importKey( - "raw", // raw format of the key - should be Uint8Array - key, // the key to import - { - // algorithm details - name: "HMAC", - hash: { name: "SHA-256" }, - }, - false, // export = false - ["sign", "verify"], // what this key can do - ); const signature = await crypto.subtle.sign( "HMAC", - importedKey, // the key to use for signing + key, // the key to use for signing data, ); return new Uint8Array(signature); From 21a5ae5f33f85246bfadc6fafd293edf29be0fe1 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 23:09:32 +0200 Subject: [PATCH 38/41] rename --- ...ify-signature.ts => is-valid-signature.ts} | 10 +++---- src/methods/verify.ts | 4 +-- .../is-valid-prefixed-signature.bench.ts | 22 ++++++++++++++ test/benchmarks/verify-signature.bench.ts | 22 -------------- ...ts => is-valid-prefixed-signature.test.ts} | 30 +++++++++---------- 5 files changed, 44 insertions(+), 44 deletions(-) rename src/common/{verify-signature.ts => is-valid-signature.ts} (84%) create mode 100644 test/benchmarks/is-valid-prefixed-signature.bench.ts delete mode 100644 test/benchmarks/verify-signature.bench.ts rename test/common/{verify-signature.test.ts => is-valid-prefixed-signature.test.ts} (78%) diff --git a/src/common/verify-signature.ts b/src/common/is-valid-signature.ts similarity index 84% rename from src/common/verify-signature.ts rename to src/common/is-valid-signature.ts index ea6cb3de..1d0f3332 100644 --- a/src/common/verify-signature.ts +++ b/src/common/is-valid-signature.ts @@ -2,7 +2,7 @@ import type { PrefixedSignatureString } from "../types.js"; const signatureRE = /^sha256=[\da-fA-F]{64}$/; -export const verifyPrefixedSignatureString = RegExp.prototype.test.bind( +export const isValidPrefixedSignatureString = RegExp.prototype.test.bind( signatureRE, ) as (value: string) => value is `sha256=${string}`; /** @@ -12,15 +12,15 @@ export const verifyPrefixedSignatureString = RegExp.prototype.test.bind( * @param value - The value to verify. * @returns {value is `sha256=${string}|Uint8Array`} `true` if the value is a valid SHA-256 signature, `false` otherwise. */ -export const verifyPrefixedSignature = ( +export const isValidPrefixedSignature = ( value: string | Uint8Array, ): value is typeof value extends string ? PrefixedSignatureString : Uint8Array => { if (typeof value === "string") { - return verifyPrefixedSignatureString(value); + return isValidPrefixedSignatureString(value); } else { - return verifyPrefixedSignatureUint8Array(value); + return isValidPrefixedSignatureUint8Array(value); } }; @@ -33,7 +33,7 @@ for (let i = 0; i < 6; i++) { notHexChars[i + 0x41] = false; // A-F } -export const verifyPrefixedSignatureUint8Array = ( +export const isValidPrefixedSignatureUint8Array = ( value: Uint8Array, ): value is Uint8Array => { if (value.length !== 71) { diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 31c941ed..613fa1c4 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -1,7 +1,7 @@ import type { Verifier } from "../types.js"; import { isAsyncFunction } from "../common/is-async-function.js"; import { prefixedSignatureStringToUint8Array } from "../common/signature-to-uint8array.js"; -import { verifyPrefixedSignatureString } from "../common/verify-signature.js"; +import { isValidPrefixedSignatureString } from "../common/is-valid-signature.js"; import { VERSION } from "../version.js"; type VerifierFactoryOptions = { @@ -33,7 +33,7 @@ export function verifyFactory({ ); } - if (verifyPrefixedSignatureString(signature) === false) { + if (isValidPrefixedSignatureString(signature) === false) { return false; } diff --git a/test/benchmarks/is-valid-prefixed-signature.bench.ts b/test/benchmarks/is-valid-prefixed-signature.bench.ts new file mode 100644 index 00000000..f4a213d8 --- /dev/null +++ b/test/benchmarks/is-valid-prefixed-signature.bench.ts @@ -0,0 +1,22 @@ +import { bench, describe } from "vitest"; +import { + isValidPrefixedSignature, + isValidPrefixedSignatureString, + isValidPrefixedSignatureUint8Array, +} from "../../src/common/is-valid-signature.ts"; + +describe("isValidPrefixedSignature", () => { + const signature = + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; + const signatureUint8Array = new TextEncoder().encode(signature); + + bench("isValidPrefixedSignature", async () => { + isValidPrefixedSignature(signature); + }); + bench("isValidPrefixedSignatureString", async () => { + isValidPrefixedSignatureString(signature); + }); + bench("isValidPrefixedSignatureUint8Array", async () => { + isValidPrefixedSignatureUint8Array(signatureUint8Array); + }); +}); diff --git a/test/benchmarks/verify-signature.bench.ts b/test/benchmarks/verify-signature.bench.ts deleted file mode 100644 index 6e48103b..00000000 --- a/test/benchmarks/verify-signature.bench.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { bench, describe } from "vitest"; -import { - verifyPrefixedSignature, - verifyPrefixedSignatureString, - verifyPrefixedSignatureUint8Array, -} from "../../src/common/verify-signature.ts"; - -describe("verifyPrefixedSignature", () => { - const signature = - "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; - const signatureUint8Array = new TextEncoder().encode(signature); - - bench("verifyPrefixedSignature", async () => { - verifyPrefixedSignature(signature); - }); - bench("verifyPrefixedSignatureString", async () => { - verifyPrefixedSignatureString(signature); - }); - bench("verifyPrefixedSignatureUint8Array", async () => { - verifyPrefixedSignatureUint8Array(signatureUint8Array); - }); -}); diff --git a/test/common/verify-signature.test.ts b/test/common/is-valid-prefixed-signature.test.ts similarity index 78% rename from test/common/verify-signature.test.ts rename to test/common/is-valid-prefixed-signature.test.ts index e1760326..89680e60 100644 --- a/test/common/verify-signature.test.ts +++ b/test/common/is-valid-prefixed-signature.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect } from "../test-runner.ts"; -import { verifyPrefixedSignature } from "../../src/common/verify-signature.ts"; +import { isValidPrefixedSignature } from "../../src/common/is-valid-signature.ts"; const textEncoder = new TextEncoder(); -describe("verifyPrefixedSignature", () => { +describe("isValidPrefixedSignature", () => { it("should return false for too short signature", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", ), ).toBe(false); expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", ), @@ -20,12 +20,12 @@ describe("verifyPrefixedSignature", () => { it("should return false for too long signature", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", ), ).toBe(false); expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", ), @@ -35,12 +35,12 @@ describe("verifyPrefixedSignature", () => { it("should return false for invalid algorithm", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(false); expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), @@ -50,7 +50,7 @@ describe("verifyPrefixedSignature", () => { it("should return false for missing algorithm", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), @@ -59,18 +59,18 @@ describe("verifyPrefixedSignature", () => { }); it("should return false for empty signature", () => { - expect(verifyPrefixedSignature("")).toBe(false); - expect(verifyPrefixedSignature(new Uint8Array())).toBe(false); + expect(isValidPrefixedSignature("")).toBe(false); + expect(isValidPrefixedSignature(new Uint8Array())).toBe(false); }); it("should return false for invalid character", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), ).toBe(false); expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", ), @@ -80,12 +80,12 @@ describe("verifyPrefixedSignature", () => { it("should return true for valid signature", () => { expect( - verifyPrefixedSignature( + isValidPrefixedSignature( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), ).toBe(true); expect( - verifyPrefixedSignature( + isValidPrefixedSignature( textEncoder.encode( "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", ), From 70e220c5c92c84d46d05bb7a406356b15bf11708 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 23:38:49 +0200 Subject: [PATCH 39/41] use again webcrypto for verification --- src/index.ts | 5 ++--- src/methods/verify.ts | 19 ++++++++++--------- src/node/crypto-verify.ts | 11 +++++++++++ src/node/timing-safe-equal.ts | 3 --- src/web.ts | 5 ++--- src/web/crypto-verify.ts | 7 +++++++ src/web/timing-safe-equal.ts | 12 ------------ test/benchmarks/timing-safe-equal.bench.ts | 20 -------------------- 8 files changed, 32 insertions(+), 50 deletions(-) create mode 100644 src/node/crypto-verify.ts delete mode 100644 src/node/timing-safe-equal.ts create mode 100644 src/web/crypto-verify.ts delete mode 100644 src/web/timing-safe-equal.ts delete mode 100644 test/benchmarks/timing-safe-equal.bench.ts diff --git a/src/index.ts b/src/index.ts index 95e502b5..2a33d51c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,9 @@ import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; import { createKeyFromSecret } from "./node/create-key-from-secret.js"; +import { cryptoVerify } from "./node/crypto-verify.js"; import { stringToUint8Array } from "./node/string-to-uint8array.js"; import { hmacSha256 } from "./node/hmac-sha256.js"; -import { timingSafeEqual } from "./node/timing-safe-equal.js"; export const sign = signFactory({ createKeyFromSecret, @@ -15,9 +15,8 @@ export const sign = signFactory({ }); export const verify = verifyFactory({ createKeyFromSecret, - hmacSha256, stringToUint8Array, - timingSafeEqual, + cryptoVerify, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); export { VERSION }; diff --git a/src/methods/verify.ts b/src/methods/verify.ts index 613fa1c4..f2d3e105 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -6,19 +6,21 @@ import { VERSION } from "../version.js"; type VerifierFactoryOptions = { createKeyFromSecret: (secret: string) => any | Promise; - hmacSha256: (key: any, data: Uint8Array) => Uint8Array | Promise; + cryptoVerify( + key: any, + data: Uint8Array, + signature: Uint8Array, + ): boolean | Promise; stringToUint8Array: (input: string) => Uint8Array; - timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean; }; export function verifyFactory({ createKeyFromSecret, - hmacSha256, + cryptoVerify, stringToUint8Array, - timingSafeEqual, }: VerifierFactoryOptions): Verifier { const createKeyFromSecretIsAsync = isAsyncFunction(createKeyFromSecret); - const hmacSha256IsAsync = isAsyncFunction(hmacSha256); + const cryptoVerifyIsAsync = isAsyncFunction(cryptoVerify); const verify: Verifier = async function verify(secret, payload, signature) { if (!secret || !payload || !signature) { @@ -41,12 +43,11 @@ export function verifyFactory({ ? await createKeyFromSecret(secret) : createKeyFromSecret(secret); const payloadBuffer = stringToUint8Array(payload); - const verificationBuffer = hmacSha256IsAsync - ? ((await hmacSha256(key, payloadBuffer)) as Uint8Array) - : (hmacSha256(key, payloadBuffer) as Uint8Array); const signatureBuffer = prefixedSignatureStringToUint8Array(signature); - return timingSafeEqual(signatureBuffer, verificationBuffer); + return cryptoVerifyIsAsync + ? ((await cryptoVerify(key, payloadBuffer, signatureBuffer)) as boolean) + : (cryptoVerify(key, payloadBuffer, signatureBuffer) as boolean); }; verify.VERSION = VERSION; diff --git a/src/node/crypto-verify.ts b/src/node/crypto-verify.ts new file mode 100644 index 00000000..a49be4b8 --- /dev/null +++ b/src/node/crypto-verify.ts @@ -0,0 +1,11 @@ +import { timingSafeEqual } from "node:crypto"; + +import { hmacSha256 } from "./hmac-sha256.js"; + +export function cryptoVerify( + key: Uint8Array, + data: Uint8Array, + signature: Uint8Array, +): boolean { + return timingSafeEqual(signature, hmacSha256(key, data)); +} diff --git a/src/node/timing-safe-equal.ts b/src/node/timing-safe-equal.ts deleted file mode 100644 index 8fc17a2c..00000000 --- a/src/node/timing-safe-equal.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto"; - -export const timingSafeEqual = cryptoTimingSafeEqual; diff --git a/src/web.ts b/src/web.ts index 4e57eb7d..de09b8ce 100644 --- a/src/web.ts +++ b/src/web.ts @@ -4,9 +4,9 @@ import { signFactory } from "./methods/sign.js"; import { VERSION } from "./version.js"; import { createKeyFromSecret } from "./web/create-key-from-secret.js"; +import { cryptoVerify } from "./web/crypto-verify.js"; import { hmacSha256 } from "./web/hmac-sha256.js"; import { stringToUint8Array } from "./web/string-to-uint8array.js"; -import { timingSafeEqual } from "./web/timing-safe-equal.js"; export const sign = signFactory({ createKeyFromSecret, @@ -15,9 +15,8 @@ export const sign = signFactory({ }); export const verify = verifyFactory({ createKeyFromSecret, - hmacSha256, + cryptoVerify, stringToUint8Array, - timingSafeEqual, }); export const verifyWithFallback = verifyWithFallbackFactory({ verify }); export { VERSION }; diff --git a/src/web/crypto-verify.ts b/src/web/crypto-verify.ts new file mode 100644 index 00000000..6f6069c0 --- /dev/null +++ b/src/web/crypto-verify.ts @@ -0,0 +1,7 @@ +export async function cryptoVerify( + key: Awaited>, + data: Uint8Array, + signature: Uint8Array, +): Promise { + return await crypto.subtle.verify("HMAC", key, signature, data); +} diff --git a/src/web/timing-safe-equal.ts b/src/web/timing-safe-equal.ts deleted file mode 100644 index f6a6da49..00000000 --- a/src/web/timing-safe-equal.ts +++ /dev/null @@ -1,12 +0,0 @@ -// constant time comparison to prevent timing attacks -// https://stackoverflow.com/a/31096242/206879 -// https://en.wikipedia.org/wiki/Timing_attack -export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { - var len = a.length; - var out = 0; - var i = -1; - while (++i < len) { - out |= a[i] ^ b[i]; - } - return out === 0; -} diff --git a/test/benchmarks/timing-safe-equal.bench.ts b/test/benchmarks/timing-safe-equal.bench.ts deleted file mode 100644 index e28f0eb1..00000000 --- a/test/benchmarks/timing-safe-equal.bench.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { bench, describe } from "vitest"; -import { timingSafeEqual as timingSafeEqualNode } from "../../src/node/timing-safe-equal.ts"; -import { timingSafeEqual as timingSafeEqualWeb } from "../../src/web/timing-safe-equal.ts"; - -describe("timingSafeEqual", () => { - const eventPayload = JSON.stringify({ - foo: "bar", - }); - - const payload = new TextEncoder().encode(eventPayload); - const payload2 = new TextEncoder().encode(eventPayload); - - bench("node", () => { - timingSafeEqualNode(payload, payload2); - }); - - bench("web", () => { - timingSafeEqualWeb(payload, payload2); - }); -}); From 6d782c5bae8358b8a452946dfe56bef5ba9975ee Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 23:55:47 +0200 Subject: [PATCH 40/41] accept Uint8Array --- src/methods/sign.ts | 11 +++- src/methods/verify-with-fallback.ts | 2 +- src/methods/verify.ts | 13 +++-- src/types.ts | 13 ++++- test/sign.test.ts | 35 +++++++----- test/verify-with-fallback.test.ts | 28 +++++++--- test/verify.test.ts | 85 +++++++++++++++++++---------- 7 files changed, 125 insertions(+), 62 deletions(-) diff --git a/src/methods/sign.ts b/src/methods/sign.ts index 95d001eb..424c8be6 100644 --- a/src/methods/sign.ts +++ b/src/methods/sign.ts @@ -23,16 +23,21 @@ export function signFactory({ ); } - if (typeof payload !== "string") { + let payloadBuffer: Uint8Array; + + if (typeof payload === "string") { + payloadBuffer = stringToUint8Array(payload); + } else if (payload instanceof Uint8Array) { + payloadBuffer = payload; + } else { throw new TypeError( - "[@octokit/webhooks-methods] payload must be a string", + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", ); } const key = createKeyFromSecretIsAsync ? await createKeyFromSecret(secret) : createKeyFromSecret(secret); - const payloadBuffer = stringToUint8Array(payload); const signature = hmacSha256IsAsync ? ((await hmacSha256(key, payloadBuffer)) as Uint8Array) diff --git a/src/methods/verify-with-fallback.ts b/src/methods/verify-with-fallback.ts index 1fbded81..3b4a4851 100644 --- a/src/methods/verify-with-fallback.ts +++ b/src/methods/verify-with-fallback.ts @@ -7,7 +7,7 @@ export function verifyWithFallbackFactory({ }): VerifyWithFallback { const verifyWithFallback = async function verifyWithFallback( secret: string, - payload: string, + payload: string | Uint8Array, signature: string, additionalSecrets: undefined | string[], ): Promise { diff --git a/src/methods/verify.ts b/src/methods/verify.ts index f2d3e105..3c3120e6 100644 --- a/src/methods/verify.ts +++ b/src/methods/verify.ts @@ -25,13 +25,19 @@ export function verifyFactory({ const verify: Verifier = async function verify(secret, payload, signature) { if (!secret || !payload || !signature) { throw new TypeError( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", + "[@octokit/webhooks-methods] secret, payload & signature required", ); } - if (typeof payload !== "string") { + let payloadBuffer: Uint8Array; + + if (typeof payload === "string") { + payloadBuffer = stringToUint8Array(payload); + } else if (payload instanceof Uint8Array) { + payloadBuffer = payload; + } else { throw new TypeError( - "[@octokit/webhooks-methods] eventPayload must be a string", + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", ); } @@ -42,7 +48,6 @@ export function verifyFactory({ const key = createKeyFromSecretIsAsync ? await createKeyFromSecret(secret) : createKeyFromSecret(secret); - const payloadBuffer = stringToUint8Array(payload); const signatureBuffer = prefixedSignatureStringToUint8Array(signature); return cryptoVerifyIsAsync diff --git a/src/types.ts b/src/types.ts index 5cd338d6..527b4799 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,26 @@ export type PrefixedSignatureString = `sha256=${string}`; export type Signer = { - (secret: string, payload: string): Promise; + ( + secret: string, + payload: string | Uint8Array, + ): Promise; VERSION: string; }; export type Verifier = { - (secret: string, payload: string, signature: string): Promise; + ( + secret: string, + payload: string | Uint8Array, + signature: string, + ): Promise; VERSION: string; }; export type VerifyWithFallback = { ( secret: string, - payload: string, + payload: string | Uint8Array, signature: string, additionalSecrets?: undefined | string[], ): Promise; diff --git a/test/sign.test.ts b/test/sign.test.ts index 92fcab95..8fc27af2 100644 --- a/test/sign.test.ts +++ b/test/sign.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect } from "./test-runner.ts"; import { sign as signNode } from "../src/index.ts"; import { sign as signWeb } from "../src/web.ts"; -const eventPayload = { +const payload = { foo: "bar", }; const secret = "mysecret"; +const textEncoder = new TextEncoder(); + [ ["node", signNode], ["web", signWeb], @@ -32,32 +34,39 @@ const secret = "mysecret"; it("throws without secret", async () => { // @ts-ignore - await expect(sign(undefined, eventPayload)).rejects.toThrow( + await expect(sign(undefined, payload)).rejects.toThrow( "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); - it("throws without eventPayload", async () => { + it("throws without payload", async () => { // @ts-expect-error await expect(sign(secret)).rejects.toThrow( "[@octokit/webhooks-methods] secret & payload required for sign()", ); }); - describe("with eventPayload as string", () => { - describe("returns expected sha256 signature", () => { - it("sign(secret, eventPayload)", async () => { - const signature = await sign(secret, JSON.stringify(eventPayload)); - expect(signature).toBe( - "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", - ); - }); + describe("with payload returns expected sha256 signature", () => { + it("payload as string", async () => { + const signature = await sign(secret, JSON.stringify(payload)); + expect(signature).toBe( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ); + }); + it("payload as Uint8Array", async () => { + const signature = await sign( + secret, + textEncoder.encode(JSON.stringify(payload)), + ); + expect(signature).toBe( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ); }); }); - it("throws with eventPayload as object", async () => { + it("throws with payload as object", async () => { // @ts-expect-error - await expect(sign(secret, eventPayload)).rejects.toThrow( + await expect(sign(secret, payload)).rejects.toThrow( "[@octokit/webhooks-methods] payload must be a string", ); }); diff --git a/test/verify-with-fallback.test.ts b/test/verify-with-fallback.test.ts index 3adc773b..f7623803 100644 --- a/test/verify-with-fallback.test.ts +++ b/test/verify-with-fallback.test.ts @@ -3,12 +3,14 @@ import { verifyWithFallback as verifyWithFallbackNode } from "../src/index.ts"; import { verifyWithFallback as verifyWithFallbackWeb } from "../src/web.ts"; import { toNormalizedJsonString } from "./common.ts"; -const JSONeventPayload = { foo: "bar" }; -const eventPayload = toNormalizedJsonString(JSONeventPayload); +const JSONPayload = { foo: "bar" }; +const payload = toNormalizedJsonString(JSONPayload); const secret = "mysecret"; const signatureSHA256 = "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; +const textEncoder = new TextEncoder(); + [ ["node", verifyWithFallbackNode], ["web", verifyWithFallbackWeb], @@ -24,30 +26,40 @@ const signatureSHA256 = expect(typeof verifyWithFallback).toBe("function"); }); - it("verifyWithFallback(secret, eventPayload, signatureSHA256, [bogus]) returns true", async () => { + it("verifyWithFallback(secret, payload, signatureSHA256, [bogus]) returns true", async () => { const signatureMatches = await verifyWithFallback( secret, - eventPayload, + payload, signatureSHA256, ["foo"], ); expect(signatureMatches).toBe(true); }); - it("verifyWithFallback(bogus, eventPayload, signatureSHA256, [secret]) returns true", async () => { + it("verifyWithFallback(bogus, payload, signatureSHA256, [secret]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + payload, + signatureSHA256, + [secret], + ); + expect(signatureMatches).toBe(true); + }); + + it("verifyWithFallback(bogus, payload, signatureSHA256, [secret]) returns true", async () => { const signatureMatches = await verifyWithFallback( "foo", - eventPayload, + textEncoder.encode(payload), signatureSHA256, [secret], ); expect(signatureMatches).toBe(true); }); - it("verify(bogus, eventPayload, signatureSHA256, [bogus]) returns false", async () => { + it("verify(bogus, payload, signatureSHA256, [bogus]) returns false", async () => { const signatureMatches = await verifyWithFallback( "foo", - eventPayload, + payload, signatureSHA256, ["foo"], ); diff --git a/test/verify.test.ts b/test/verify.test.ts index 4f3e373c..0769d2f9 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -3,12 +3,14 @@ import { verify as verifyNode } from "../src/index.ts"; import { verify as verifyWeb } from "../src/web.ts"; import { toNormalizedJsonString } from "./common.ts"; -const JSONeventPayload = { foo: "bar" }; -const eventPayload = toNormalizedJsonString(JSONeventPayload); +const JSONPayload = { foo: "bar" }; +const payload = toNormalizedJsonString(JSONPayload); const secret = "mysecret"; -const signatureSHA256 = +const signature = "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; +const textEncoder = new TextEncoder(); + [ ["node", verifyNode], ["web", verifyWeb], @@ -28,55 +30,47 @@ const signatureSHA256 = it("verify() without options throws", async () => { // @ts-expect-error await expect(verify()).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", + "[@octokit/webhooks-methods] secret, payload & signature required", ); }); - it("verify(undefined, eventPayload) without secret throws", async () => { + it("verify(undefined, payload) without secret throws", async () => { // @ts-expect-error - await expect(verify(undefined, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", + await expect(verify(undefined, payload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, payload & signature required", ); }); - it("verify(secret) without eventPayload throws", async () => { + it("verify(secret) without payload throws", async () => { // @ts-expect-error await expect(verify(secret)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", + "[@octokit/webhooks-methods] secret, payload & signature required", ); }); - it("verify(secret, eventPayload) without options.signature throws", async () => { + it("verify(secret, payload) without options.signature throws", async () => { // @ts-expect-error - await expect(verify(secret, eventPayload)).rejects.toThrow( - "[@octokit/webhooks-methods] secret, eventPayload & signature required", + await expect(verify(secret, payload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, payload & signature required", ); }); - it("verify(secret, eventPayload, signatureSHA256) returns true for correct signature", async () => { - const signatureMatches = await verify( - secret, - eventPayload, - signatureSHA256, - ); + it("verify(secret, payload, signature) returns true for correct signature", async () => { + const signatureMatches = await verify(secret, payload, signature); expect(signatureMatches).toBe(true); }); - it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect signature", async () => { - const signatureMatches = await verify(secret, eventPayload, "foo"); + it("verify(secret, payload, signature) returns false for incorrect signature", async () => { + const signatureMatches = await verify(secret, payload, "foo"); expect(signatureMatches).toBe(false); }); - it("verify(secret, eventPayload, signatureSHA256) returns false for incorrect secret", async () => { - const signatureMatches = await verify( - "foo", - eventPayload, - signatureSHA256, - ); + it("verify(secret, payload, signature) returns false for incorrect secret", async () => { + const signatureMatches = await verify("foo", payload, signature); expect(signatureMatches).toBe(false); }); - it("verify(secret, eventPayload, signatureSHA256) returns true if eventPayload contains special characters (#71)", async () => { + it("verify(secret, payload, signature) returns true if payload contains special characters (#71)", async () => { // https://github.com/octokit/webhooks.js/issues/71 const signatureMatchesLowerCaseSequence = await verify( "development", @@ -102,14 +96,45 @@ const signatureSHA256 = "sha256=6f8326efbacfbd04e870cea25b5652e635be8c9807f2fd5348ef60753c9e96ed", ); expect(signatureMatchesEscapedSequence).toBe(true); + // https://github.com/octokit/webhooks.js/issues/71 + const signatureMatchesLowerCaseSequenceUint8Array = await verify( + "development", + textEncoder.encode( + toNormalizedJsonString({ + foo: "Foo\n\u001b[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001b[0m\u001b[2K", + }), + ), + "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", + ); + expect(signatureMatchesLowerCaseSequenceUint8Array).toBe(true); + const signatureMatchesUpperCaseSequenceUint8Array = await verify( + "development", + textEncoder.encode( + toNormalizedJsonString({ + foo: "Foo\n\u001B[34mbar: ♥♥♥♥♥♥♥♥\nthis-is-lost\u001B[0m\u001B[2K", + }), + ), + "sha256=afecc3caa27548bb90d51a50384cb2868b9a3327b4ad6a01c9bd4ed0f8b0b12c", + ); + expect(signatureMatchesUpperCaseSequenceUint8Array).toBe(true); + const signatureMatchesEscapedSequenceUint8Array = await verify( + "development", + textEncoder.encode( + toNormalizedJsonString({ + foo: "\\u001b", + }), + ), + "sha256=6f8326efbacfbd04e870cea25b5652e635be8c9807f2fd5348ef60753c9e96ed", + ); + expect(signatureMatchesEscapedSequenceUint8Array).toBe(true); }); - it("verify(secret, eventPayload, signatureSHA256) with JSON eventPayload", async () => { + it("verify(secret, payload, signature) with JSON payload", async () => { await expect( // @ts-expect-error - verify(secret, JSONeventPayload, signatureSHA256), + verify(secret, JSONPayload, signature), ).rejects.toThrow( - "[@octokit/webhooks-methods] eventPayload must be a string", + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", ); }); }); From 98a937029a2a39abdf3a0ae5570b93001e462187 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 8 Jul 2025 23:56:42 +0200 Subject: [PATCH 41/41] improve test --- test/sign.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sign.test.ts b/test/sign.test.ts index 8fc27af2..8c9eed96 100644 --- a/test/sign.test.ts +++ b/test/sign.test.ts @@ -67,7 +67,7 @@ const textEncoder = new TextEncoder(); it("throws with payload as object", async () => { // @ts-expect-error await expect(sign(secret, payload)).rejects.toThrow( - "[@octokit/webhooks-methods] payload must be a string", + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", ); }); });