Skip to content

feat: refactor webhooks-methods.js #335

New issue

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

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

Already on GitHub? Sign in to your account

Draft
wants to merge 41 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c4bb276
feat: refactor webhooks-methods.js
Uzlopak Jul 6, 2025
9fbd2e2
implement concatUint8Array
Uzlopak Jul 6, 2025
c383ec2
fix testing
Uzlopak Jul 6, 2025
43f6e34
more internal byte operations
Uzlopak Jul 6, 2025
71c0019
remove unnecessary type checks
Uzlopak Jul 6, 2025
d5259dc
changes push
Uzlopak Jul 6, 2025
a47e8c2
simplify
Uzlopak Jul 6, 2025
52607e0
blub
Uzlopak Jul 6, 2025
3d56480
fix uint8array-to-hex
Uzlopak Jul 6, 2025
b3be82c
add more tests
Uzlopak Jul 6, 2025
d15bd5f
improve
Uzlopak Jul 6, 2025
3a269ca
more
Uzlopak Jul 6, 2025
7480c6b
increase perf
Uzlopak Jul 6, 2025
86288b3
remove redundant check
Uzlopak Jul 6, 2025
4a393f4
improve
Uzlopak Jul 7, 2025
2e8fa47
improve
Uzlopak Jul 7, 2025
5ed2a61
fix name
Uzlopak Jul 7, 2025
565435c
generate buffers
Uzlopak Jul 7, 2025
902cfe6
test web parts in node too
Uzlopak Jul 7, 2025
025ef46
use deno v2
Uzlopak Jul 7, 2025
1588c5e
fix benchmarks
Uzlopak Jul 7, 2025
f9fab01
move benchmarks to benchmarks folder
Uzlopak Jul 7, 2025
b7ee2da
split tests for verify
Uzlopak Jul 7, 2025
cc9af33
add verify with fallback benchmark
Uzlopak Jul 7, 2025
71da788
use faster Buffer.from
Uzlopak Jul 7, 2025
00d0fe0
await hmacSha256 only if async
Uzlopak Jul 8, 2025
f869698
rename eventPayload to payload
Uzlopak Jul 8, 2025
b88e164
improve testing
Uzlopak Jul 8, 2025
be9b394
add bun to test
Uzlopak Jul 8, 2025
294c4b1
fix coverage
Uzlopak Jul 8, 2025
c777750
remove unused file
Uzlopak Jul 8, 2025
99e3a20
remove sha256
Uzlopak Jul 8, 2025
370de55
improve coverage
Uzlopak Jul 8, 2025
724b570
fix
Uzlopak Jul 8, 2025
d11a68d
use again webcrypto for web
Uzlopak Jul 8, 2025
5358c79
remove dead code
Uzlopak Jul 8, 2025
3cb3a06
improve
Uzlopak Jul 8, 2025
21a5ae5
rename
Uzlopak Jul 8, 2025
70e220c
use again webcrypto for verification
Uzlopak Jul 8, 2025
6d782c5
accept Uint8Array
Uzlopak Jul 8, 2025
98a9370
improve test
Uzlopak Jul 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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() }}
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 8 additions & 0 deletions src/common/is-async-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* c8 ignore next */
const AsyncFunctionConstructor = (async () => {}).constructor;

export function isAsyncFunction(
fn: unknown,
): fn is (...args: unknown[]) => Promise<unknown> {
return fn instanceof AsyncFunctionConstructor;
}
61 changes: 61 additions & 0 deletions src/common/is-valid-signature.ts
Original file line number Diff line number Diff line change
@@ -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;
};
68 changes: 68 additions & 0 deletions src/common/signature-to-uint8array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { PrefixedSignatureString } from "../types.js";

const hexLookUpHighByte: Record<string, number> = {
"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<string, number> = {
"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;
}
81 changes: 81 additions & 0 deletions src/common/uint8array-to-signature.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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;
}
45 changes: 20 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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 };
50 changes: 50 additions & 0 deletions src/methods/sign.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
hmacSha256: (key: any, data: Uint8Array) => Uint8Array | Promise<Uint8Array>;
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;
}
Loading
Loading