hono-webhooks 0.5.0 → 0.6.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.
@@ -1,5 +1,6 @@
1
1
  // src/signatures.ts
2
2
  var encoder = new TextEncoder;
3
+ var reject = (input, reason) => input.fail?.(reason) ?? false;
3
4
  function hmacKey(secret, hash) {
4
5
  if (!secret.length) {
5
6
  throw new Error("hono-webhooks: the signing secret is empty");
@@ -29,19 +30,23 @@ function fromBase64(base64) {
29
30
  }
30
31
  }
31
32
  var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
33
+ var signedUrls = async (url, c) => [await url?.(c) ?? c.req.url].flat();
34
+ var outsideTolerance = (seconds, toleranceSeconds) => Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds;
32
35
  function hmac(options) {
33
36
  const { header, encoding, prefix = "", hash = "SHA-256" } = options;
34
37
  const name = header.toLowerCase();
35
38
  return {
36
- verify: (secret) => async ({ c, rawBody, headers }) => {
37
- const value = headers[name];
38
- if (!value?.startsWith(prefix))
39
- return false;
39
+ verify: (secret) => async (input) => {
40
+ const value = input.headers[name];
41
+ if (!value)
42
+ return reject(input, "missing-header");
43
+ if (!value.startsWith(prefix))
44
+ return reject(input, "malformed");
40
45
  const encoded = value.slice(prefix.length);
41
46
  const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
42
47
  if (!signature)
43
- return false;
44
- return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
48
+ return reject(input, "malformed");
49
+ return await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature, hash) || reject(input, "mismatch");
45
50
  },
46
51
  async sign(rawBody, secret) {
47
52
  const signature = await hmacSign(secret, rawBody, hash);
@@ -56,27 +61,31 @@ var github = hmac({
56
61
  encoding: "hex",
57
62
  prefix: "sha256="
58
63
  });
64
+ var meta = github;
59
65
  var shopify = hmac({
60
66
  header: "x-shopify-hmac-sha256",
61
67
  encoding: "base64"
62
68
  });
63
69
  var stripe = {
64
- verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
65
- const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
70
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
71
+ const header = input.headers["stripe-signature"];
72
+ if (!header)
73
+ return reject(input, "missing-header");
74
+ const parts = header.split(",").map((part) => part.split("="));
66
75
  const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
67
76
  if (!/^\d+$/.test(timestamp))
68
- return false;
69
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
70
- return false;
77
+ return reject(input, "malformed");
78
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
79
+ return reject(input, "timestamp-out-of-tolerance");
71
80
  }
72
- const key = await resolveSecret(secret, c);
81
+ const key = await resolveSecret(secret, input.c);
73
82
  for (const [k, v] of parts) {
74
83
  const signature = k === "v1" && v ? fromHex(v) : undefined;
75
- if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
84
+ if (signature && await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature)) {
76
85
  return true;
77
86
  }
78
87
  }
79
- return false;
88
+ return reject(input, "mismatch");
80
89
  },
81
90
  async sign(rawBody, secret, { timestamp } = {}) {
82
91
  const t = timestamp ?? Math.floor(Date.now() / 1000);
@@ -92,25 +101,29 @@ function svixKey(secret) {
92
101
  return key;
93
102
  }
94
103
  var svix = {
95
- verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
104
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
105
+ const { headers } = input;
96
106
  const prefix = headers["svix-id"] ? "svix" : "webhook";
97
107
  const id = headers[`${prefix}-id`];
98
- const timestamp = headers[`${prefix}-timestamp`] ?? "";
108
+ const timestamp = headers[`${prefix}-timestamp`];
99
109
  const signatures = headers[`${prefix}-signature`];
100
- if (!id || !signatures || !/^\d+$/.test(timestamp))
101
- return false;
102
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
103
- return false;
110
+ if (!id || !signatures || !timestamp) {
111
+ return reject(input, "missing-header");
104
112
  }
105
- const key = svixKey(await resolveSecret(secret, c));
113
+ if (!/^\d+$/.test(timestamp))
114
+ return reject(input, "malformed");
115
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
116
+ return reject(input, "timestamp-out-of-tolerance");
117
+ }
118
+ const key = svixKey(await resolveSecret(secret, input.c));
106
119
  for (const entry of signatures.split(" ")) {
107
120
  const [version, encoded] = entry.split(",");
108
121
  const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
109
- if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
122
+ if (signature && await hmacVerify(key, `${id}.${timestamp}.${input.rawBody}`, signature)) {
110
123
  return true;
111
124
  }
112
125
  }
113
- return false;
126
+ return reject(input, "mismatch");
114
127
  },
115
128
  async sign(rawBody, secret, { id, timestamp } = {}) {
116
129
  const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
@@ -123,10 +136,144 @@ var svix = {
123
136
  };
124
137
  }
125
138
  };
139
+ var compareKey;
140
+ async function secretsEqual(given, expected) {
141
+ if (!expected)
142
+ throw new Error("hono-webhooks: the shared secret is empty");
143
+ compareKey ??= crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
144
+ const key = await compareKey;
145
+ const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(expected));
146
+ return crypto.subtle.verify("HMAC", key, mac, encoder.encode(given));
147
+ }
148
+ function sharedSecret(options) {
149
+ const { prefix = "" } = options;
150
+ const name = options.header.toLowerCase();
151
+ return {
152
+ verify: (secret) => async (input) => {
153
+ const value = input.headers[name];
154
+ if (!value)
155
+ return reject(input, "missing-header");
156
+ if (!value.startsWith(prefix))
157
+ return reject(input, "malformed");
158
+ return await secretsEqual(value.slice(prefix.length), await resolveSecret(secret, input.c)) || reject(input, "mismatch");
159
+ },
160
+ async sign(_rawBody, secret) {
161
+ return { [name]: `${prefix}${secret}` };
162
+ }
163
+ };
164
+ }
165
+ var infisical = {
166
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
167
+ const header = input.headers["x-infisical-signature"];
168
+ if (!header)
169
+ return reject(input, "missing-header");
170
+ const signature = fromHex(header.split(";")[1] ?? "");
171
+ if (!signature?.length)
172
+ return reject(input, "malformed");
173
+ let timestamp;
174
+ try {
175
+ timestamp = JSON.parse(input.rawBody)?.timestamp;
176
+ } catch {}
177
+ if (typeof timestamp !== "number")
178
+ return reject(input, "malformed");
179
+ if (!await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature)) {
180
+ return reject(input, "mismatch");
181
+ }
182
+ return !outsideTolerance(timestamp / 1000, toleranceSeconds) || reject(input, "timestamp-out-of-tolerance");
183
+ },
184
+ async sign(rawBody, secret) {
185
+ let t;
186
+ try {
187
+ t = JSON.parse(rawBody)?.timestamp;
188
+ } catch {}
189
+ const signature = toHex(await hmacSign(secret, rawBody));
190
+ return { "x-infisical-signature": `t=${t ?? Date.now()};${signature}` };
191
+ }
192
+ };
193
+ function twilioPayload(url, rawBody) {
194
+ const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
195
+ return url + fields.map(([k, v]) => k + v).join("");
196
+ }
197
+ var twilio = {
198
+ verify: (secret, { url } = {}) => async (input) => {
199
+ const signature = input.headers["x-twilio-signature"];
200
+ if (!signature)
201
+ return reject(input, "missing-header");
202
+ const bytes = fromBase64(signature);
203
+ if (!bytes)
204
+ return reject(input, "malformed");
205
+ const key = await resolveSecret(secret, input.c);
206
+ for (const candidate of await signedUrls(url, input.c)) {
207
+ const payload = twilioPayload(candidate, input.rawBody);
208
+ if (await hmacVerify(key, payload, bytes, "SHA-1"))
209
+ return true;
210
+ }
211
+ return reject(input, "mismatch");
212
+ },
213
+ async sign(rawBody, secret, options) {
214
+ if (!options?.url)
215
+ throw new Error("hono-webhooks: twilio.sign needs a url");
216
+ const signature = await hmacSign(secret, twilioPayload(options.url, rawBody), "SHA-1");
217
+ return { "x-twilio-signature": toBase64(signature) };
218
+ }
219
+ };
220
+ var HUBSPOT_DECODE = {
221
+ "%3A": ":",
222
+ "%2F": "/",
223
+ "%3F": "?",
224
+ "%40": "@",
225
+ "%21": "!",
226
+ "%24": "$",
227
+ "%27": "'",
228
+ "%28": "(",
229
+ "%29": ")",
230
+ "%2A": "*",
231
+ "%2C": ",",
232
+ "%3B": ";"
233
+ };
234
+ var hubspotUrl = (url) => url.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi, (match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match);
235
+ var hubspot = {
236
+ verify: (secret, { url, toleranceSeconds = 300 } = {}) => async (input) => {
237
+ const signature = input.headers["x-hubspot-signature-v3"];
238
+ const timestamp = input.headers["x-hubspot-request-timestamp"];
239
+ if (!signature || !timestamp)
240
+ return reject(input, "missing-header");
241
+ const bytes = fromBase64(signature);
242
+ if (!bytes || !/^\d+$/.test(timestamp))
243
+ return reject(input, "malformed");
244
+ if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
245
+ return reject(input, "timestamp-out-of-tolerance");
246
+ }
247
+ const key = await resolveSecret(secret, input.c);
248
+ const method = input.c.req.method.toUpperCase();
249
+ for (const candidate of await signedUrls(url, input.c)) {
250
+ const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`;
251
+ if (await hmacVerify(key, payload, bytes))
252
+ return true;
253
+ }
254
+ return reject(input, "mismatch");
255
+ },
256
+ async sign(rawBody, secret, options) {
257
+ if (!options?.url)
258
+ throw new Error("hono-webhooks: hubspot.sign needs a url");
259
+ const t = options.timestamp ?? Date.now();
260
+ const method = (options.method ?? "POST").toUpperCase();
261
+ const signature = await hmacSign(secret, `${method}${hubspotUrl(options.url)}${rawBody}${t}`);
262
+ return {
263
+ "x-hubspot-signature-v3": toBase64(signature),
264
+ "x-hubspot-request-timestamp": String(t)
265
+ };
266
+ }
267
+ };
126
268
  export {
127
269
  github,
128
270
  hmac,
271
+ hubspot,
272
+ infisical,
273
+ meta,
274
+ sharedSecret,
129
275
  shopify,
130
276
  stripe,
131
- svix
277
+ svix,
278
+ twilio
132
279
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-webhooks",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,7 +88,7 @@
88
88
  "@opentelemetry/sdk-trace-base": "^2.12.0",
89
89
  "@types/bun": "^1.4.2",
90
90
  "@typescript/native-preview": "^7.0.0-dev.20260707.2",
91
- "hanzio": "^3.1.0",
91
+ "hanzio": "^3.4.0",
92
92
  "hono": "^4.13.10",
93
93
  "husky": "^9.1.7",
94
94
  "typescript": "^7.0.2",