diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d340b6..a39166f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,18 +32,24 @@ 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: - 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 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 c59fe8e..9d2a2e1 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,12 @@ "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:deno": "cd test/deno && deno test", - "test:browser": "node test/browser-test.js" + "test": "npm run -s test:node && npm run -s test:deno && npm run test:bun && npm run -s test:browser", + "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" }, "repository": "github:octokit/webhooks-methods.js", "keywords": [ diff --git a/src/common/is-async-function.ts b/src/common/is-async-function.ts new file mode 100644 index 0000000..5ca601a --- /dev/null +++ b/src/common/is-async-function.ts @@ -0,0 +1,8 @@ +/* c8 ignore next */ +const AsyncFunctionConstructor = (async () => {}).constructor; + +export function isAsyncFunction( + fn: unknown, +): fn is (...args: unknown[]) => Promise { + return fn instanceof AsyncFunctionConstructor; +} diff --git a/src/common/is-valid-signature.ts b/src/common/is-valid-signature.ts new file mode 100644 index 0000000..1d0f333 --- /dev/null +++ b/src/common/is-valid-signature.ts @@ -0,0 +1,61 @@ +import type { PrefixedSignatureString } from "../types.js"; + +const signatureRE = /^sha256=[\da-fA-F]{64}$/; + +export const isValidPrefixedSignatureString = 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. + * + * @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 isValidPrefixedSignature = ( + value: string | Uint8Array, +): value is typeof value extends string + ? PrefixedSignatureString + : Uint8Array => { + if (typeof value === "string") { + return isValidPrefixedSignatureString(value); + } else { + return isValidPrefixedSignatureUint8Array(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 isValidPrefixedSignatureUint8Array = ( + value: Uint8Array, +): value is Uint8Array => { + if (value.length !== 71) { + return false; + } + + if ( + 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[6] !== 0x3d // '=' character + ) { + return false; + } + + for (let i = 7; i < 71; i++) { + if (notHexChars[value[i]]) { + return false; + } + } + return true; +}; diff --git a/src/common/signature-to-uint8array.ts b/src/common/signature-to-uint8array.ts new file mode 100644 index 0000000..fb4577f --- /dev/null +++ b/src/common/signature-to-uint8array.ts @@ -0,0 +1,68 @@ +import type { PrefixedSignatureString } 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 prefixedSignatureStringToUint8Array( + prefixedSignature: PrefixedSignatureString, +): 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 new file mode 100644 index 0000000..deffc4a --- /dev/null +++ b/src/common/uint8array-to-signature.ts @@ -0,0 +1,81 @@ +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, + 0x64, 0x65, 0x66, +]; + +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 uint8arrayToPrefixedSignature( + 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; // '=' + + let i = 0, + offset = 7; + + while (i < 32) { + prefixedSignature[offset++] = hexLookUpHighByte[signature[i]]; + prefixedSignature[offset++] = hexLookUpLowByte[signature[i++]]; + } + 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/index.ts b/src/index.ts index 5756ce1..2a33d51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,22 @@ -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 { 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"; - 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({ + createKeyFromSecret, + hmacSha256, + stringToUint8Array, +}); +export const verify = verifyFactory({ + createKeyFromSecret, + stringToUint8Array, + cryptoVerify, +}); +export const verifyWithFallback = verifyWithFallbackFactory({ verify }); +export { VERSION }; diff --git a/src/methods/sign.ts b/src/methods/sign.ts new file mode 100644 index 0000000..424c8be --- /dev/null +++ b/src/methods/sign.ts @@ -0,0 +1,50 @@ +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"; + +type SignerFactoryOptions = { + 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) { + if (!secret || !payload) { + throw new TypeError( + "[@octokit/webhooks-methods] secret & payload required for sign()", + ); + } + + 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 or Uint8Array", + ); + } + + const key = createKeyFromSecretIsAsync + ? await createKeyFromSecret(secret) + : createKeyFromSecret(secret); + + const signature = hmacSha256IsAsync + ? ((await hmacSha256(key, payloadBuffer)) as Uint8Array) + : (hmacSha256(key, payloadBuffer) as Uint8Array); + + return uint8arrayToPrefixedSignatureString(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 0000000..3b4a485 --- /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 | Uint8Array, + 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 0000000..3c3120e --- /dev/null +++ b/src/methods/verify.ts @@ -0,0 +1,60 @@ +import type { Verifier } from "../types.js"; +import { isAsyncFunction } from "../common/is-async-function.js"; +import { prefixedSignatureStringToUint8Array } from "../common/signature-to-uint8array.js"; +import { isValidPrefixedSignatureString } from "../common/is-valid-signature.js"; +import { VERSION } from "../version.js"; + +type VerifierFactoryOptions = { + createKeyFromSecret: (secret: string) => any | Promise; + cryptoVerify( + key: any, + data: Uint8Array, + signature: Uint8Array, + ): boolean | Promise; + stringToUint8Array: (input: string) => Uint8Array; +}; + +export function verifyFactory({ + createKeyFromSecret, + cryptoVerify, + stringToUint8Array, +}: VerifierFactoryOptions): Verifier { + const createKeyFromSecretIsAsync = isAsyncFunction(createKeyFromSecret); + const cryptoVerifyIsAsync = isAsyncFunction(cryptoVerify); + + const verify: Verifier = async function verify(secret, payload, signature) { + if (!secret || !payload || !signature) { + throw new TypeError( + "[@octokit/webhooks-methods] secret, payload & signature required", + ); + } + + 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 or Uint8Array", + ); + } + + if (isValidPrefixedSignatureString(signature) === false) { + return false; + } + + const key = createKeyFromSecretIsAsync + ? await createKeyFromSecret(secret) + : createKeyFromSecret(secret); + const signatureBuffer = prefixedSignatureStringToUint8Array(signature); + + return cryptoVerifyIsAsync + ? ((await cryptoVerify(key, payloadBuffer, signatureBuffer)) as boolean) + : (cryptoVerify(key, payloadBuffer, signatureBuffer) as boolean); + }; + + verify.VERSION = VERSION; + return verify; +} diff --git a/src/node/create-key-from-secret.ts b/src/node/create-key-from-secret.ts new file mode 100644 index 0000000..f211827 --- /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/node/crypto-verify.ts b/src/node/crypto-verify.ts new file mode 100644 index 0000000..a49be4b --- /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/hmac-sha256.ts b/src/node/hmac-sha256.ts new file mode 100644 index 0000000..e655454 --- /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/node/sign.ts b/src/node/sign.ts deleted file mode 100644 index b2a606c..0000000 --- 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/string-to-uint8array.ts b/src/node/string-to-uint8array.ts new file mode 100644 index 0000000..fc8d269 --- /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/node/verify.ts b/src/node/verify.ts deleted file mode 100644 index 816867d..0000000 --- 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 0000000..527b479 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,27 @@ +export type PrefixedSignatureString = `sha256=${string}`; + +export type Signer = { + ( + secret: string, + payload: string | Uint8Array, + ): Promise; + VERSION: string; +}; + +export type Verifier = { + ( + secret: string, + payload: string | Uint8Array, + signature: string, + ): Promise; + VERSION: string; +}; + +export type VerifyWithFallback = { + ( + secret: string, + payload: string | Uint8Array, + signature: string, + additionalSecrets?: undefined | string[], + ): Promise; +}; diff --git a/src/web.ts b/src/web.ts index d5fb4fc..de09b8c 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,104 +1,22 @@ -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 { 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"; + +export const sign = signFactory({ + createKeyFromSecret, + hmacSha256, + stringToUint8Array, +}); +export const verify = verifyFactory({ + createKeyFromSecret, + cryptoVerify, + stringToUint8Array, +}); +export const verifyWithFallback = verifyWithFallbackFactory({ verify }); +export { VERSION }; diff --git a/src/web/create-key-from-secret.ts b/src/web/create-key-from-secret.ts new file mode 100644 index 0000000..4fe2d59 --- /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/crypto-verify.ts b/src/web/crypto-verify.ts new file mode 100644 index 0000000..6f6069c --- /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/hmac-sha256.ts b/src/web/hmac-sha256.ts new file mode 100644 index 0000000..2b7e5ff --- /dev/null +++ b/src/web/hmac-sha256.ts @@ -0,0 +1,11 @@ +export async function hmacSha256( + key: Awaited>, + data: Uint8Array, +): Promise { + const signature = await crypto.subtle.sign( + "HMAC", + key, // the key to use for signing + data, + ); + return new Uint8Array(signature); +} diff --git a/src/web/string-to-uint8array.ts b/src/web/string-to-uint8array.ts new file mode 100644 index 0000000..935a825 --- /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/benchmark-sign.bench.ts b/test/benchmark-sign.bench.ts deleted file mode 100644 index 289bb7c..0000000 --- a/test/benchmark-sign.bench.ts +++ /dev/null @@ -1,19 +0,0 @@ -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"; - -describe("sign", () => { - const eventPayload = toNormalizedJsonString({ - foo: "bar", - }); - const secret = "mysecret"; - - bench("node", async () => { - await signNode(secret, JSON.stringify(eventPayload)); - }); - - bench("web", async () => { - await signWeb(secret, JSON.stringify(eventPayload)); - }); -}); diff --git a/test/benchmark-verify.bench.ts b/test/benchmark-verify.bench.ts deleted file mode 100644 index 847a2ee..0000000 --- a/test/benchmark-verify.bench.ts +++ /dev/null @@ -1,22 +0,0 @@ -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"; - -describe("verify", () => { - const eventPayload = toNormalizedJsonString({ - foo: "bar", - }); - const secret = "mysecret"; - - const signatureSHA256 = - "sha256=e3eccac34c43c7dc1cbb905488b1b81347fcc700a7b025697a9d07862256023f"; - - bench("node", async () => { - await verifyNode(secret, eventPayload, signatureSHA256); - }); - - bench("web", async () => { - await verifyWeb(secret, eventPayload, signatureSHA256); - }); -}); diff --git a/test/benchmarks/hmac-sha256.bench.ts b/test/benchmarks/hmac-sha256.bench.ts new file mode 100644 index 0000000..0fe464d --- /dev/null +++ b/test/benchmarks/hmac-sha256.bench.ts @@ -0,0 +1,20 @@ +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("sha256 - web", async () => { + await hmacSha256Web(key, data); + }); +}); 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 0000000..f4a213d --- /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/sign.bench.ts b/test/benchmarks/sign.bench.ts new file mode 100644 index 0000000..c9174f7 --- /dev/null +++ b/test/benchmarks/sign.bench.ts @@ -0,0 +1,19 @@ +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"; + +describe("sign", () => { + const payload = toNormalizedJsonString({ + foo: "bar", + }); + const secret = "mysecret"; + + bench("node", async () => { + await signNode(secret, payload); + }); + + bench("web", async () => { + await signWeb(secret, payload); + }); +}); diff --git a/test/benchmarks/string-to-uint8array.bench.ts b/test/benchmarks/string-to-uint8array.bench.ts new file mode 100644 index 0000000..f5d6536 --- /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); + }); +}); diff --git a/test/benchmarks/uint8array-to-signature-string.bench.ts b/test/benchmarks/uint8array-to-signature-string.bench.ts new file mode 100644 index 0000000..b675bc0 --- /dev/null +++ b/test/benchmarks/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.ts"; + +describe("uint8arrayToPrefixedSignatureString", () => { + const payload = randomBytes(32); + + bench("uint8arrayToPrefixedSignatureString", () => { + uint8arrayToPrefixedSignatureString(payload); + }); +}); diff --git a/test/benchmarks/verify-with-fallback.bench.ts b/test/benchmarks/verify-with-fallback.bench.ts new file mode 100644 index 0000000..52736a7 --- /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, + ); + }); +}); diff --git a/test/benchmarks/verify.bench.ts b/test/benchmarks/verify.bench.ts new file mode 100644 index 0000000..ff0cec7 --- /dev/null +++ b/test/benchmarks/verify.bench.ts @@ -0,0 +1,22 @@ +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"; + +describe("verify", async () => { + const payload = toNormalizedJsonString({ + foo: "bar", + }); + const secret = "mysecret"; + + const signatureSHA256 = + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; + + bench("node", async () => { + await verifyNode(secret, payload, signatureSHA256); + }); + + bench("web", async () => { + await verifyWeb(secret, payload, signatureSHA256); + }); +}); diff --git a/test/browser-test.js b/test/browser-test.js index c9ab416..36f00c4 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."); } diff --git a/test/common/is-async-function.test.ts b/test/common/is-async-function.test.ts new file mode 100644 index 0000000..5036b3f --- /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); + }); +}); diff --git a/test/common/is-valid-prefixed-signature.test.ts b/test/common/is-valid-prefixed-signature.test.ts new file mode 100644 index 0000000..89680e6 --- /dev/null +++ b/test/common/is-valid-prefixed-signature.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "../test-runner.ts"; +import { isValidPrefixedSignature } from "../../src/common/is-valid-signature.ts"; + +const textEncoder = new TextEncoder(); +describe("isValidPrefixedSignature", () => { + it("should return false for too short signature", () => { + expect( + isValidPrefixedSignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", + ), + ).toBe(false); + expect( + isValidPrefixedSignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda", + ), + ), + ).toBe(false); + }); + + it("should return false for too long signature", () => { + expect( + isValidPrefixedSignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", + ), + ).toBe(false); + expect( + isValidPrefixedSignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3a", + ), + ), + ).toBe(false); + }); + + it("should return false for invalid algorithm", () => { + expect( + isValidPrefixedSignature( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ).toBe(false); + expect( + isValidPrefixedSignature( + textEncoder.encode( + "sha258=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ), + ).toBe(false); + }); + + it("should return false for missing algorithm", () => { + expect( + isValidPrefixedSignature( + textEncoder.encode( + "4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ), + ).toBe(false); + }); + + it("should return false for empty signature", () => { + expect(isValidPrefixedSignature("")).toBe(false); + expect(isValidPrefixedSignature(new Uint8Array())).toBe(false); + }); + + it("should return false for invalid character", () => { + expect( + isValidPrefixedSignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + ), + ).toBe(false); + expect( + isValidPrefixedSignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bdaz", + ), + ), + ).toBe(false); + }); + + it("should return true for valid signature", () => { + expect( + isValidPrefixedSignature( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ).toBe(true); + expect( + isValidPrefixedSignature( + textEncoder.encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ), + ).toBe(true); + }); +}); diff --git a/test/common/uint8array-to-signature.test.ts b/test/common/uint8array-to-signature.test.ts new file mode 100644 index 0000000..33183c6 --- /dev/null +++ b/test/common/uint8array-to-signature.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "../test-runner.ts"; +import { uint8arrayToPrefixedSignature } from "../../src/common/uint8array-to-signature.ts"; + +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 = uint8arrayToPrefixedSignature(uint8array); + expect(signature).toStrictEqual( + new TextEncoder().encode( + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3", + ), + ); + }); +}); diff --git a/test/deno/deno.json b/test/deno/deno.json deleted file mode 100644 index c58a02f..0000000 --- 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/web_test.ts b/test/deno/web_test.ts deleted file mode 100644 index 3772947..0000000 --- a/test/deno/web_test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { sign, verify, verifyWithFallback } from "../../pkg/dist-web/index.js"; - -import { assertEquals } from "@std/assert"; - -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); -}); diff --git a/test/sign.test.ts b/test/sign.test.ts index 21d9869..8c9eed9 100644 --- a/test/sign.test.ts +++ b/test/sign.test.ts @@ -1,56 +1,75 @@ -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 = { +const payload = { foo: "bar", }; const secret = "mysecret"; -describe("sign", () => { - it("is a function", () => { - expect(sign).toBeInstanceOf(Function); - }); +const textEncoder = new TextEncoder(); - it("sign.VERSION is set", () => { - expect(sign.VERSION).toEqual("0.0.0-development"); - }); +[ + ["node", signNode], + ["web", signWeb], +].forEach((tuple) => { + const [environment, sign] = tuple as [string, typeof signNode]; - it("throws without options throws", async () => { - // @ts-expect-error - await expect(() => sign()).rejects.toThrow( - "[@octokit/webhooks-methods] secret & payload required for sign()", - ); - }); + describe(environment, () => { + describe("sign", () => { + it("is a function", () => { + expect(typeof sign).toBe("function"); + }); - it("throws without secret", async () => { - // @ts-ignore - await expect(() => sign(undefined, eventPayload)).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 eventPayload", async () => { - // @ts-expect-error - await expect(() => sign(secret)).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()", + ); + }); - 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 secret", async () => { + // @ts-ignore + await expect(sign(undefined, payload)).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", - ); + it("throws without payload", async () => { + // @ts-expect-error + await expect(sign(secret)).rejects.toThrow( + "[@octokit/webhooks-methods] secret & payload required for sign()", + ); + }); + + 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 payload as object", async () => { + // @ts-expect-error + await expect(sign(secret, payload)).rejects.toThrow( + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", + ); + }); + }); }); }); diff --git a/test/test-runner.ts b/test/test-runner.ts new file mode 100644 index 0000000..3f63857 --- /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-with-fallback.test.ts b/test/verify-with-fallback.test.ts new file mode 100644 index 0000000..f762380 --- /dev/null +++ b/test/verify-with-fallback.test.ts @@ -0,0 +1,70 @@ +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 JSONPayload = { foo: "bar" }; +const payload = toNormalizedJsonString(JSONPayload); +const secret = "mysecret"; +const signatureSHA256 = + "sha256=4864d2759938a15468b5df9ade20bf161da9b4f737ea61794142f3484236bda3"; + +const textEncoder = new TextEncoder(); + +[ + ["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, payload, signatureSHA256, [bogus]) returns true", async () => { + const signatureMatches = await verifyWithFallback( + secret, + payload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(true); + }); + + 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", + textEncoder.encode(payload), + signatureSHA256, + [secret], + ); + expect(signatureMatches).toBe(true); + }); + + it("verify(bogus, payload, signatureSHA256, [bogus]) returns false", async () => { + const signatureMatches = await verifyWithFallback( + "foo", + payload, + signatureSHA256, + ["foo"], + ); + expect(signatureMatches).toBe(false); + }); + }); + }); +}); diff --git a/test/verify.test.ts b/test/verify.test.ts index ad431fc..0769d2f 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -1,139 +1,142 @@ -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 } 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"; -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); +const textEncoder = new TextEncoder(); + +[ + ["node", verifyNode], + ["web", verifyWeb], +].forEach((tuple) => { + const [environment, verify] = tuple as [string, typeof verifyNode]; + + 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, payload & signature required", + ); + }); + + it("verify(undefined, payload) without secret throws", async () => { + // @ts-expect-error + await expect(verify(undefined, payload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, payload & signature required", + ); + }); + + it("verify(secret) without payload throws", async () => { + // @ts-expect-error + await expect(verify(secret)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, payload & signature required", + ); + }); + + it("verify(secret, payload) without options.signature throws", async () => { + // @ts-expect-error + await expect(verify(secret, payload)).rejects.toThrow( + "[@octokit/webhooks-methods] secret, payload & signature required", + ); + }); + + 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, payload, signature) returns false for incorrect signature", async () => { + const signatureMatches = await verify(secret, payload, "foo"); + expect(signatureMatches).toBe(false); + }); + + 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, payload, signature) returns true if payload 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); + // 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, payload, signature) with JSON payload", async () => { + await expect( + // @ts-expect-error + verify(secret, JSONPayload, signature), + ).rejects.toThrow( + "[@octokit/webhooks-methods] payload must be a string or Uint8Array", + ); + }); + }); }); }); diff --git a/vite.config.js b/vite.config.js index 6bc6ed2..516c9df 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,