@algovoi/webhook-verifier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * AlgoVoi webhook signature verifier — v1 (HMAC-SHA256) + v2 (HKDF-SHA256 / HMAC-SHA384).
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { verifyWebhook, WebhookVerificationError } from "@algovoi/webhook-verifier";
7
+ *
8
+ * try {
9
+ * const event = verifyWebhook({
10
+ * payload: req.body, // raw Buffer
11
+ * secret: process.env.ALGOVOI_WEBHOOK_SECRET!,
12
+ * signatureHeader: req.headers["x-algovoi-signature"] as string,
13
+ * });
14
+ * console.log(event.type); // "payment.confirmed"
15
+ * } catch (err) {
16
+ * if (err instanceof WebhookVerificationError) {
17
+ * console.error(err.code, err.message);
18
+ * }
19
+ * }
20
+ * ```
21
+ */
22
+ type ErrorCode = "MISSING_SIGNATURE" | "MALFORMED_SIGNATURE" | "STALE_SIGNATURE" | "INVALID_SIGNATURE" | "INVALID_PAYLOAD" | "UNKNOWN_EVENT_TYPE";
23
+ declare const ERROR_CODES: ReadonlySet<ErrorCode>;
24
+ interface WebhookEvent {
25
+ id: string;
26
+ type: string;
27
+ created: number;
28
+ api_version: string;
29
+ data: Record<string, unknown>;
30
+ }
31
+ interface VerifyOptions {
32
+ /** Raw request body bytes. */
33
+ payload: Buffer | Uint8Array | string;
34
+ /** Tenant webhook signing secret (``algvw_*``). */
35
+ secret: string;
36
+ /** Value of the ``X-AlgoVoi-Signature`` header. */
37
+ signatureHeader: string;
38
+ /**
39
+ * Maximum age of a valid signature in seconds. Default: 300.
40
+ * Pass 0 to disable (test environments only).
41
+ */
42
+ tolerance?: number;
43
+ /**
44
+ * If true, the v2 signature component must be present and valid.
45
+ * Default: false (v2 validated when present, accepted when absent).
46
+ */
47
+ requireV2?: boolean;
48
+ }
49
+ declare class WebhookVerificationError extends Error {
50
+ readonly code: ErrorCode;
51
+ constructor(code: ErrorCode, message: string);
52
+ }
53
+ declare const SIGNATURE_HEADER = "X-AlgoVoi-Signature";
54
+ declare const DEFAULT_TOLERANCE = 300;
55
+ declare const KNOWN_EVENT_TYPES: ReadonlySet<string>;
56
+ /**
57
+ * Verify an AlgoVoi webhook signature and return the parsed event.
58
+ *
59
+ * @throws {WebhookVerificationError} on any verification failure.
60
+ */
61
+ declare function verifyWebhook(options: VerifyOptions): WebhookEvent;
62
+
63
+ export { DEFAULT_TOLERANCE, ERROR_CODES, type ErrorCode, KNOWN_EVENT_TYPES, SIGNATURE_HEADER, type VerifyOptions, type WebhookEvent, WebhookVerificationError, verifyWebhook };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * AlgoVoi webhook signature verifier — v1 (HMAC-SHA256) + v2 (HKDF-SHA256 / HMAC-SHA384).
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { verifyWebhook, WebhookVerificationError } from "@algovoi/webhook-verifier";
7
+ *
8
+ * try {
9
+ * const event = verifyWebhook({
10
+ * payload: req.body, // raw Buffer
11
+ * secret: process.env.ALGOVOI_WEBHOOK_SECRET!,
12
+ * signatureHeader: req.headers["x-algovoi-signature"] as string,
13
+ * });
14
+ * console.log(event.type); // "payment.confirmed"
15
+ * } catch (err) {
16
+ * if (err instanceof WebhookVerificationError) {
17
+ * console.error(err.code, err.message);
18
+ * }
19
+ * }
20
+ * ```
21
+ */
22
+ type ErrorCode = "MISSING_SIGNATURE" | "MALFORMED_SIGNATURE" | "STALE_SIGNATURE" | "INVALID_SIGNATURE" | "INVALID_PAYLOAD" | "UNKNOWN_EVENT_TYPE";
23
+ declare const ERROR_CODES: ReadonlySet<ErrorCode>;
24
+ interface WebhookEvent {
25
+ id: string;
26
+ type: string;
27
+ created: number;
28
+ api_version: string;
29
+ data: Record<string, unknown>;
30
+ }
31
+ interface VerifyOptions {
32
+ /** Raw request body bytes. */
33
+ payload: Buffer | Uint8Array | string;
34
+ /** Tenant webhook signing secret (``algvw_*``). */
35
+ secret: string;
36
+ /** Value of the ``X-AlgoVoi-Signature`` header. */
37
+ signatureHeader: string;
38
+ /**
39
+ * Maximum age of a valid signature in seconds. Default: 300.
40
+ * Pass 0 to disable (test environments only).
41
+ */
42
+ tolerance?: number;
43
+ /**
44
+ * If true, the v2 signature component must be present and valid.
45
+ * Default: false (v2 validated when present, accepted when absent).
46
+ */
47
+ requireV2?: boolean;
48
+ }
49
+ declare class WebhookVerificationError extends Error {
50
+ readonly code: ErrorCode;
51
+ constructor(code: ErrorCode, message: string);
52
+ }
53
+ declare const SIGNATURE_HEADER = "X-AlgoVoi-Signature";
54
+ declare const DEFAULT_TOLERANCE = 300;
55
+ declare const KNOWN_EVENT_TYPES: ReadonlySet<string>;
56
+ /**
57
+ * Verify an AlgoVoi webhook signature and return the parsed event.
58
+ *
59
+ * @throws {WebhookVerificationError} on any verification failure.
60
+ */
61
+ declare function verifyWebhook(options: VerifyOptions): WebhookEvent;
62
+
63
+ export { DEFAULT_TOLERANCE, ERROR_CODES, type ErrorCode, KNOWN_EVENT_TYPES, SIGNATURE_HEADER, type VerifyOptions, type WebhookEvent, WebhookVerificationError, verifyWebhook };
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_TOLERANCE: () => DEFAULT_TOLERANCE,
24
+ ERROR_CODES: () => ERROR_CODES,
25
+ KNOWN_EVENT_TYPES: () => KNOWN_EVENT_TYPES,
26
+ SIGNATURE_HEADER: () => SIGNATURE_HEADER,
27
+ WebhookVerificationError: () => WebhookVerificationError,
28
+ verifyWebhook: () => verifyWebhook
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+ var import_crypto = require("crypto");
32
+ var import_crypto2 = require("crypto");
33
+ var ERROR_CODES = /* @__PURE__ */ new Set([
34
+ "MISSING_SIGNATURE",
35
+ "MALFORMED_SIGNATURE",
36
+ "STALE_SIGNATURE",
37
+ "INVALID_SIGNATURE",
38
+ "INVALID_PAYLOAD",
39
+ "UNKNOWN_EVENT_TYPE"
40
+ ]);
41
+ var WebhookVerificationError = class _WebhookVerificationError extends Error {
42
+ code;
43
+ constructor(code, message) {
44
+ super(message);
45
+ this.name = "WebhookVerificationError";
46
+ this.code = code;
47
+ if (Error.captureStackTrace) {
48
+ Error.captureStackTrace(this, _WebhookVerificationError);
49
+ }
50
+ }
51
+ };
52
+ var SIGNATURE_HEADER = "X-AlgoVoi-Signature";
53
+ var DEFAULT_TOLERANCE = 300;
54
+ var KNOWN_EVENT_TYPES = /* @__PURE__ */ new Set(["payment.confirmed"]);
55
+ var SIG_RE = /^t=(?<ts>\d+),v1=(?<v1>[0-9a-f]{64})(?:,v2=(?<v2>[0-9a-f]{96}))?$/;
56
+ function deriveV2Key(secretBytes) {
57
+ return Buffer.from(
58
+ (0, import_crypto2.hkdfSync)(
59
+ "sha256",
60
+ secretBytes,
61
+ Buffer.from("algovoi-webhook-v2-pqc"),
62
+ Buffer.from("hmac-sha384-outbound"),
63
+ 48
64
+ )
65
+ );
66
+ }
67
+ function computeSigs(secret, ts, body) {
68
+ const secretBytes = Buffer.from(secret, "utf8");
69
+ const tsPrefix = Buffer.from(`${ts}.`, "utf8");
70
+ const signedPayload = Buffer.concat([tsPrefix, body]);
71
+ const v1 = (0, import_crypto.createHmac)("sha256", secretBytes).update(signedPayload).digest("hex");
72
+ const v2Key = deriveV2Key(secretBytes);
73
+ const v2 = (0, import_crypto.createHmac)("sha384", v2Key).update(signedPayload).digest("hex");
74
+ return { v1, v2 };
75
+ }
76
+ function timingSafeCompareHex(a, b) {
77
+ if (a.length !== b.length) return false;
78
+ return (0, import_crypto.timingSafeEqual)(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
79
+ }
80
+ function verifyWebhook(options) {
81
+ const {
82
+ payload,
83
+ secret,
84
+ signatureHeader,
85
+ tolerance = DEFAULT_TOLERANCE,
86
+ requireV2 = false
87
+ } = options;
88
+ const trimmedHeader = signatureHeader.trim();
89
+ if (!trimmedHeader) {
90
+ throw new WebhookVerificationError(
91
+ "MISSING_SIGNATURE",
92
+ "X-AlgoVoi-Signature header is absent"
93
+ );
94
+ }
95
+ const match = SIG_RE.exec(trimmedHeader);
96
+ if (!match || !match.groups) {
97
+ throw new WebhookVerificationError(
98
+ "MALFORMED_SIGNATURE",
99
+ `Signature header does not match expected format t=<unix>,v1=<sha256hex>[,v2=<sha384hex>]: ${signatureHeader}`
100
+ );
101
+ }
102
+ const ts = parseInt(match.groups.ts, 10);
103
+ const receivedV1 = match.groups.v1;
104
+ const receivedV2 = match.groups.v2;
105
+ if (tolerance > 0) {
106
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - ts);
107
+ if (age > tolerance) {
108
+ throw new WebhookVerificationError(
109
+ "STALE_SIGNATURE",
110
+ `Signature timestamp ${ts} is ${age}s old (tolerance ${tolerance}s)`
111
+ );
112
+ }
113
+ }
114
+ const body = typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
115
+ const { v1: expectedV1, v2: expectedV2 } = computeSigs(secret, ts, body);
116
+ const v1Ok = timingSafeCompareHex(receivedV1, expectedV1);
117
+ let v2Ok;
118
+ if (receivedV2 !== void 0) {
119
+ v2Ok = timingSafeCompareHex(receivedV2, expectedV2);
120
+ } else {
121
+ v2Ok = !requireV2;
122
+ }
123
+ if (!v1Ok || !v2Ok) {
124
+ throw new WebhookVerificationError(
125
+ "INVALID_SIGNATURE",
126
+ "One or more signature components did not match"
127
+ );
128
+ }
129
+ let event;
130
+ try {
131
+ event = JSON.parse(
132
+ typeof payload === "string" ? payload : body.toString("utf8")
133
+ );
134
+ } catch (err) {
135
+ throw new WebhookVerificationError(
136
+ "INVALID_PAYLOAD",
137
+ `Payload is not valid JSON: ${err.message}`
138
+ );
139
+ }
140
+ if (typeof event !== "object" || event === null || Array.isArray(event)) {
141
+ throw new WebhookVerificationError(
142
+ "INVALID_PAYLOAD",
143
+ "Payload root must be a JSON object"
144
+ );
145
+ }
146
+ const eventType = event.type;
147
+ if (!KNOWN_EVENT_TYPES.has(eventType)) {
148
+ throw new WebhookVerificationError(
149
+ "UNKNOWN_EVENT_TYPE",
150
+ `Unrecognised event type: ${JSON.stringify(eventType)}`
151
+ );
152
+ }
153
+ return event;
154
+ }
155
+ // Annotate the CommonJS export names for ESM import in node:
156
+ 0 && (module.exports = {
157
+ DEFAULT_TOLERANCE,
158
+ ERROR_CODES,
159
+ KNOWN_EVENT_TYPES,
160
+ SIGNATURE_HEADER,
161
+ WebhookVerificationError,
162
+ verifyWebhook
163
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // src/index.ts
2
+ import { createHmac, timingSafeEqual } from "crypto";
3
+ import { hkdfSync } from "crypto";
4
+ var ERROR_CODES = /* @__PURE__ */ new Set([
5
+ "MISSING_SIGNATURE",
6
+ "MALFORMED_SIGNATURE",
7
+ "STALE_SIGNATURE",
8
+ "INVALID_SIGNATURE",
9
+ "INVALID_PAYLOAD",
10
+ "UNKNOWN_EVENT_TYPE"
11
+ ]);
12
+ var WebhookVerificationError = class _WebhookVerificationError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = "WebhookVerificationError";
17
+ this.code = code;
18
+ if (Error.captureStackTrace) {
19
+ Error.captureStackTrace(this, _WebhookVerificationError);
20
+ }
21
+ }
22
+ };
23
+ var SIGNATURE_HEADER = "X-AlgoVoi-Signature";
24
+ var DEFAULT_TOLERANCE = 300;
25
+ var KNOWN_EVENT_TYPES = /* @__PURE__ */ new Set(["payment.confirmed"]);
26
+ var SIG_RE = /^t=(?<ts>\d+),v1=(?<v1>[0-9a-f]{64})(?:,v2=(?<v2>[0-9a-f]{96}))?$/;
27
+ function deriveV2Key(secretBytes) {
28
+ return Buffer.from(
29
+ hkdfSync(
30
+ "sha256",
31
+ secretBytes,
32
+ Buffer.from("algovoi-webhook-v2-pqc"),
33
+ Buffer.from("hmac-sha384-outbound"),
34
+ 48
35
+ )
36
+ );
37
+ }
38
+ function computeSigs(secret, ts, body) {
39
+ const secretBytes = Buffer.from(secret, "utf8");
40
+ const tsPrefix = Buffer.from(`${ts}.`, "utf8");
41
+ const signedPayload = Buffer.concat([tsPrefix, body]);
42
+ const v1 = createHmac("sha256", secretBytes).update(signedPayload).digest("hex");
43
+ const v2Key = deriveV2Key(secretBytes);
44
+ const v2 = createHmac("sha384", v2Key).update(signedPayload).digest("hex");
45
+ return { v1, v2 };
46
+ }
47
+ function timingSafeCompareHex(a, b) {
48
+ if (a.length !== b.length) return false;
49
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
50
+ }
51
+ function verifyWebhook(options) {
52
+ const {
53
+ payload,
54
+ secret,
55
+ signatureHeader,
56
+ tolerance = DEFAULT_TOLERANCE,
57
+ requireV2 = false
58
+ } = options;
59
+ const trimmedHeader = signatureHeader.trim();
60
+ if (!trimmedHeader) {
61
+ throw new WebhookVerificationError(
62
+ "MISSING_SIGNATURE",
63
+ "X-AlgoVoi-Signature header is absent"
64
+ );
65
+ }
66
+ const match = SIG_RE.exec(trimmedHeader);
67
+ if (!match || !match.groups) {
68
+ throw new WebhookVerificationError(
69
+ "MALFORMED_SIGNATURE",
70
+ `Signature header does not match expected format t=<unix>,v1=<sha256hex>[,v2=<sha384hex>]: ${signatureHeader}`
71
+ );
72
+ }
73
+ const ts = parseInt(match.groups.ts, 10);
74
+ const receivedV1 = match.groups.v1;
75
+ const receivedV2 = match.groups.v2;
76
+ if (tolerance > 0) {
77
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - ts);
78
+ if (age > tolerance) {
79
+ throw new WebhookVerificationError(
80
+ "STALE_SIGNATURE",
81
+ `Signature timestamp ${ts} is ${age}s old (tolerance ${tolerance}s)`
82
+ );
83
+ }
84
+ }
85
+ const body = typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
86
+ const { v1: expectedV1, v2: expectedV2 } = computeSigs(secret, ts, body);
87
+ const v1Ok = timingSafeCompareHex(receivedV1, expectedV1);
88
+ let v2Ok;
89
+ if (receivedV2 !== void 0) {
90
+ v2Ok = timingSafeCompareHex(receivedV2, expectedV2);
91
+ } else {
92
+ v2Ok = !requireV2;
93
+ }
94
+ if (!v1Ok || !v2Ok) {
95
+ throw new WebhookVerificationError(
96
+ "INVALID_SIGNATURE",
97
+ "One or more signature components did not match"
98
+ );
99
+ }
100
+ let event;
101
+ try {
102
+ event = JSON.parse(
103
+ typeof payload === "string" ? payload : body.toString("utf8")
104
+ );
105
+ } catch (err) {
106
+ throw new WebhookVerificationError(
107
+ "INVALID_PAYLOAD",
108
+ `Payload is not valid JSON: ${err.message}`
109
+ );
110
+ }
111
+ if (typeof event !== "object" || event === null || Array.isArray(event)) {
112
+ throw new WebhookVerificationError(
113
+ "INVALID_PAYLOAD",
114
+ "Payload root must be a JSON object"
115
+ );
116
+ }
117
+ const eventType = event.type;
118
+ if (!KNOWN_EVENT_TYPES.has(eventType)) {
119
+ throw new WebhookVerificationError(
120
+ "UNKNOWN_EVENT_TYPE",
121
+ `Unrecognised event type: ${JSON.stringify(eventType)}`
122
+ );
123
+ }
124
+ return event;
125
+ }
126
+ export {
127
+ DEFAULT_TOLERANCE,
128
+ ERROR_CODES,
129
+ KNOWN_EVENT_TYPES,
130
+ SIGNATURE_HEADER,
131
+ WebhookVerificationError,
132
+ verifyWebhook
133
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@algovoi/webhook-verifier",
3
+ "version": "0.1.0",
4
+ "description": "Cryptographic verifier for AlgoVoi webhook signatures (v1 HMAC-SHA256 + v2 HKDF-SHA256/HMAC-SHA384)",
5
+ "license": "Apache-2.0",
6
+ "author": "AlgoVoi <dev@algovoi.co.uk>",
7
+ "homepage": "https://docs.algovoi.co.uk/webhook-verifier",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/chopmob-cloud/algovoi-webhook-verifier"
11
+ },
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs",
19
+ "types": "./dist/index.d.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "keywords": [
26
+ "webhook",
27
+ "hmac",
28
+ "signature",
29
+ "verification",
30
+ "algovoi"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup src/index.ts --format esm,cjs --dts",
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^25.9.1",
42
+ "tsup": "^8.0.0",
43
+ "typescript": "^5.4.0",
44
+ "vitest": "^1.6.0"
45
+ }
46
+ }