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.
package/src/signatures.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Context } from 'hono'
2
- import type { Verify } from './index'
2
+ import type { Verify, VerifyInput } from './index'
3
3
 
4
4
  /** A signing secret, or a function reading it per request (e.g. from `c.env`). */
5
5
  export type Secret = string | ((c: Context<any>) => string | Promise<string>)
@@ -25,8 +25,31 @@ export interface HmacOptions {
25
25
  hash?: 'SHA-1' | 'SHA-256' | 'SHA-512'
26
26
  }
27
27
 
28
+ /**
29
+ * The URL a provider signed. Behind a proxy `c.req.url` is the internal URL,
30
+ * so return the public one; return several and any match passes.
31
+ * @default (c) => c.req.url
32
+ */
33
+ export type SignedUrl = (
34
+ c: Context<any>
35
+ ) => string | readonly string[] | Promise<string | readonly string[]>
36
+
37
+ /**
38
+ * Why a built-in scheme rejected a request, passed to `fail` and so to
39
+ * `onEvent` as `validation.issues[0].message`.
40
+ */
41
+ export type VerifyFailureReason =
42
+ | 'missing-header'
43
+ | 'malformed'
44
+ | 'timestamp-out-of-tolerance'
45
+ | 'mismatch'
46
+
28
47
  const encoder = new TextEncoder()
29
48
 
49
+ /** `fail` is optional here so a scheme called by hand with a bare input still works. */
50
+ const reject = (input: VerifyInput, reason: VerifyFailureReason) =>
51
+ input.fail?.(reason) ?? false
52
+
30
53
  /** A string secret is used as UTF-8; bytes are used as is. */
31
54
  function hmacKey(secret: string | Uint8Array, hash: string) {
32
55
  if (!secret.length) {
@@ -91,6 +114,12 @@ function fromBase64(base64: string) {
91
114
  const resolveSecret = async (secret: Secret, c: Context) =>
92
115
  typeof secret === 'function' ? secret(c) : secret
93
116
 
117
+ const signedUrls = async (url: SignedUrl | undefined, c: Context) =>
118
+ [(await url?.(c)) ?? c.req.url].flat()
119
+
120
+ const outsideTolerance = (seconds: number, toleranceSeconds: number) =>
121
+ Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds
122
+
94
123
  /**
95
124
  * A scheme where one header carries an HMAC of the raw body. Covers most
96
125
  * providers; `github` and `shopify` are built with it.
@@ -102,22 +131,23 @@ export function hmac(options: HmacOptions): SignatureScheme {
102
131
  const { header, encoding, prefix = '', hash = 'SHA-256' } = options
103
132
  const name = header.toLowerCase()
104
133
  return {
105
- verify:
106
- (secret) =>
107
- async ({ c, rawBody, headers }) => {
108
- const value = headers[name]
109
- if (!value?.startsWith(prefix)) return false
110
- const encoded = value.slice(prefix.length)
111
- const signature =
112
- encoding === 'hex' ? fromHex(encoded) : fromBase64(encoded)
113
- if (!signature) return false
114
- return hmacVerify(
115
- await resolveSecret(secret, c),
116
- rawBody,
134
+ verify: (secret) => async (input) => {
135
+ const value = input.headers[name]
136
+ if (!value) return reject(input, 'missing-header')
137
+ if (!value.startsWith(prefix)) return reject(input, 'malformed')
138
+ const encoded = value.slice(prefix.length)
139
+ const signature =
140
+ encoding === 'hex' ? fromHex(encoded) : fromBase64(encoded)
141
+ if (!signature) return reject(input, 'malformed')
142
+ return (
143
+ (await hmacVerify(
144
+ await resolveSecret(secret, input.c),
145
+ input.rawBody,
117
146
  signature,
118
147
  hash
119
- )
120
- },
148
+ )) || reject(input, 'mismatch')
149
+ )
150
+ },
121
151
  async sign(rawBody, secret) {
122
152
  const signature = await hmacSign(secret, rawBody, hash)
123
153
  return {
@@ -134,6 +164,9 @@ export const github = hmac({
134
164
  prefix: 'sha256='
135
165
  })
136
166
 
167
+ /** Meta (WhatsApp, Messenger, Instagram): signs like GitHub, with the app secret. */
168
+ export const meta = github
169
+
137
170
  /** Shopify: `x-shopify-hmac-sha256: <base64>`. */
138
171
  export const shopify = hmac({
139
172
  header: 'x-shopify-hmac-sha256',
@@ -150,27 +183,27 @@ export const stripe: SignatureScheme<
150
183
  > = {
151
184
  verify:
152
185
  (secret, { toleranceSeconds = 300 } = {}) =>
153
- async ({ c, rawBody, headers }) => {
154
- const parts = (headers['stripe-signature'] ?? '')
155
- .split(',')
156
- .map((part) => part.split('='))
186
+ async (input) => {
187
+ const header = input.headers['stripe-signature']
188
+ if (!header) return reject(input, 'missing-header')
189
+ const parts = header.split(',').map((part) => part.split('='))
157
190
  // Signed over the timestamp exactly as sent.
158
191
  const timestamp = parts.find(([k]) => k === 't')?.[1] ?? ''
159
- if (!/^\d+$/.test(timestamp)) return false
160
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
161
- return false
192
+ if (!/^\d+$/.test(timestamp)) return reject(input, 'malformed')
193
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
194
+ return reject(input, 'timestamp-out-of-tolerance')
162
195
  }
163
- const key = await resolveSecret(secret, c)
196
+ const key = await resolveSecret(secret, input.c)
164
197
  for (const [k, v] of parts) {
165
198
  const signature = k === 'v1' && v ? fromHex(v) : undefined
166
199
  if (
167
200
  signature &&
168
- (await hmacVerify(key, `${timestamp}.${rawBody}`, signature))
201
+ (await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature))
169
202
  ) {
170
203
  return true
171
204
  }
172
205
  }
173
- return false
206
+ return reject(input, 'mismatch')
174
207
  },
175
208
  async sign(rawBody, secret, { timestamp } = {}) {
176
209
  const t = timestamp ?? Math.floor(Date.now() / 1000)
@@ -201,28 +234,36 @@ export const svix: SignatureScheme<
201
234
  > = {
202
235
  verify:
203
236
  (secret, { toleranceSeconds = 300 } = {}) =>
204
- async ({ c, rawBody, headers }) => {
237
+ async (input) => {
238
+ const { headers } = input
205
239
  const prefix = headers['svix-id'] ? 'svix' : 'webhook'
206
240
  const id = headers[`${prefix}-id`]
207
- const timestamp = headers[`${prefix}-timestamp`] ?? ''
241
+ const timestamp = headers[`${prefix}-timestamp`]
208
242
  const signatures = headers[`${prefix}-signature`]
209
- if (!id || !signatures || !/^\d+$/.test(timestamp)) return false
210
- if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
211
- return false
243
+ if (!id || !signatures || !timestamp) {
244
+ return reject(input, 'missing-header')
245
+ }
246
+ if (!/^\d+$/.test(timestamp)) return reject(input, 'malformed')
247
+ if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
248
+ return reject(input, 'timestamp-out-of-tolerance')
212
249
  }
213
- const key = svixKey(await resolveSecret(secret, c))
250
+ const key = svixKey(await resolveSecret(secret, input.c))
214
251
  for (const entry of signatures.split(' ')) {
215
252
  const [version, encoded] = entry.split(',')
216
253
  const signature =
217
254
  version === 'v1' && encoded ? fromBase64(encoded) : undefined
218
255
  if (
219
256
  signature &&
220
- (await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature))
257
+ (await hmacVerify(
258
+ key,
259
+ `${id}.${timestamp}.${input.rawBody}`,
260
+ signature
261
+ ))
221
262
  ) {
222
263
  return true
223
264
  }
224
265
  }
225
- return false
266
+ return reject(input, 'mismatch')
226
267
  },
227
268
  async sign(rawBody, secret, { id, timestamp } = {}) {
228
269
  const msgId = id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`
@@ -238,3 +279,206 @@ export const svix: SignatureScheme<
238
279
  }
239
280
  }
240
281
  }
282
+
283
+ export interface SharedSecretOptions {
284
+ /** Header carrying the secret, e.g. `x-gitlab-token` or `authorization`. */
285
+ header: string
286
+ /** Text before the secret in the header, e.g. `Bearer `. */
287
+ prefix?: string
288
+ }
289
+
290
+ // Web Crypto has no timingSafeEqual: HMAC the expected secret under a random
291
+ // key, then let crypto.subtle.verify (constant time) check the given one.
292
+ let compareKey: Promise<CryptoKey> | undefined
293
+ async function secretsEqual(given: string, expected: string) {
294
+ if (!expected) throw new Error('hono-webhooks: the shared secret is empty')
295
+ compareKey ??= crypto.subtle.generateKey(
296
+ { name: 'HMAC', hash: 'SHA-256' },
297
+ false,
298
+ ['sign', 'verify']
299
+ ) as Promise<CryptoKey>
300
+ const key = await compareKey
301
+ const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(expected))
302
+ return crypto.subtle.verify('HMAC', key, mac, encoder.encode(given))
303
+ }
304
+
305
+ /**
306
+ * A secret sent as is in a header (GitLab's `x-gitlab-token`, Telegram's
307
+ * `x-telegram-bot-api-secret-token`, `authorization: Bearer <token>`),
308
+ * compared in constant time. An empty secret throws (a 500).
309
+ *
310
+ * @example
311
+ * verify: sharedSecret({ header: 'x-gitlab-token' }).verify((c) => c.env.GITLAB_TOKEN)
312
+ */
313
+ export function sharedSecret(options: SharedSecretOptions): SignatureScheme {
314
+ const { prefix = '' } = options
315
+ const name = options.header.toLowerCase()
316
+ return {
317
+ verify: (secret) => async (input) => {
318
+ const value = input.headers[name]
319
+ if (!value) return reject(input, 'missing-header')
320
+ if (!value.startsWith(prefix)) return reject(input, 'malformed')
321
+ return (
322
+ (await secretsEqual(
323
+ value.slice(prefix.length),
324
+ await resolveSecret(secret, input.c)
325
+ )) || reject(input, 'mismatch')
326
+ )
327
+ },
328
+ async sign(_rawBody, secret) {
329
+ return { [name]: `${prefix}${secret}` }
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Infisical: `x-infisical-signature: t=<ms>;<hex>`, an HMAC-SHA256 of the
336
+ * JSON body. The body's own `timestamp` (milliseconds) is what is signed, so
337
+ * that is what must be within `toleranceSeconds` (default 300).
338
+ */
339
+ export const infisical: SignatureScheme<{ toleranceSeconds?: number }> = {
340
+ verify:
341
+ (secret, { toleranceSeconds = 300 } = {}) =>
342
+ async (input) => {
343
+ const header = input.headers['x-infisical-signature']
344
+ if (!header) return reject(input, 'missing-header')
345
+ const signature = fromHex(header.split(';')[1] ?? '')
346
+ if (!signature?.length) return reject(input, 'malformed')
347
+ let timestamp: unknown
348
+ try {
349
+ timestamp = JSON.parse(input.rawBody)?.timestamp
350
+ } catch {}
351
+ if (typeof timestamp !== 'number') return reject(input, 'malformed')
352
+ if (
353
+ !(await hmacVerify(
354
+ await resolveSecret(secret, input.c),
355
+ input.rawBody,
356
+ signature
357
+ ))
358
+ ) {
359
+ return reject(input, 'mismatch')
360
+ }
361
+ return (
362
+ !outsideTolerance(timestamp / 1000, toleranceSeconds) ||
363
+ reject(input, 'timestamp-out-of-tolerance')
364
+ )
365
+ },
366
+ async sign(rawBody, secret) {
367
+ let t: unknown
368
+ try {
369
+ t = JSON.parse(rawBody)?.timestamp
370
+ } catch {}
371
+ const signature = toHex(await hmacSign(secret, rawBody))
372
+ return { 'x-infisical-signature': `t=${t ?? Date.now()};${signature}` }
373
+ }
374
+ }
375
+
376
+ /** Twilio signs the URL followed by each form field's name and value, sorted by name. */
377
+ function twilioPayload(url: string, rawBody: string) {
378
+ const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) =>
379
+ a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1
380
+ )
381
+ return url + fields.map(([k, v]) => k + v).join('')
382
+ }
383
+
384
+ /**
385
+ * Twilio: `x-twilio-signature: <base64>`, an HMAC-SHA1 with the auth token
386
+ * over the full public URL and the sorted form fields (`bodyType: 'form'`).
387
+ * Pass `url` when `c.req.url` is not the URL Twilio called.
388
+ *
389
+ * @example
390
+ * verify: twilio.verify((c) => c.env.TWILIO_AUTH_TOKEN, { url: (c) => publicUrl(c) })
391
+ */
392
+ export const twilio: SignatureScheme<{ url?: SignedUrl }, { url: string }> = {
393
+ verify:
394
+ (secret, { url } = {}) =>
395
+ async (input) => {
396
+ const signature = input.headers['x-twilio-signature']
397
+ if (!signature) return reject(input, 'missing-header')
398
+ const bytes = fromBase64(signature)
399
+ if (!bytes) return reject(input, 'malformed')
400
+ const key = await resolveSecret(secret, input.c)
401
+ for (const candidate of await signedUrls(url, input.c)) {
402
+ const payload = twilioPayload(candidate, input.rawBody)
403
+ if (await hmacVerify(key, payload, bytes, 'SHA-1')) return true
404
+ }
405
+ return reject(input, 'mismatch')
406
+ },
407
+ async sign(rawBody, secret, options) {
408
+ if (!options?.url) throw new Error('hono-webhooks: twilio.sign needs a url')
409
+ const signature = await hmacSign(
410
+ secret,
411
+ twilioPayload(options.url, rawBody),
412
+ 'SHA-1'
413
+ )
414
+ return { 'x-twilio-signature': toBase64(signature) }
415
+ }
416
+ }
417
+
418
+ const HUBSPOT_DECODE: Record<string, string> = {
419
+ '%3A': ':',
420
+ '%2F': '/',
421
+ '%3F': '?',
422
+ '%40': '@',
423
+ '%21': '!',
424
+ '%24': '$',
425
+ '%27': "'",
426
+ '%28': '(',
427
+ '%29': ')',
428
+ '%2A': '*',
429
+ '%2C': ',',
430
+ '%3B': ';'
431
+ }
432
+
433
+ /** HubSpot decodes these escapes in the URL before signing. */
434
+ const hubspotUrl = (url: string) =>
435
+ url.replace(
436
+ /%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi,
437
+ (match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match
438
+ )
439
+
440
+ /**
441
+ * HubSpot v3: `x-hubspot-signature-v3: <base64>`, an HMAC-SHA256 with the
442
+ * app's client secret over `<METHOD><url><body><timestamp>`, and
443
+ * `x-hubspot-request-timestamp` in milliseconds, rejected when more than
444
+ * `toleranceSeconds` (default 300) away. Pass `url` when `c.req.url` is not
445
+ * the URL HubSpot called.
446
+ */
447
+ export const hubspot: SignatureScheme<
448
+ { url?: SignedUrl; toleranceSeconds?: number },
449
+ { url: string; method?: string; timestamp?: number }
450
+ > = {
451
+ verify:
452
+ (secret, { url, toleranceSeconds = 300 } = {}) =>
453
+ async (input) => {
454
+ const signature = input.headers['x-hubspot-signature-v3']
455
+ const timestamp = input.headers['x-hubspot-request-timestamp']
456
+ if (!signature || !timestamp) return reject(input, 'missing-header')
457
+ const bytes = fromBase64(signature)
458
+ if (!bytes || !/^\d+$/.test(timestamp)) return reject(input, 'malformed')
459
+ if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
460
+ return reject(input, 'timestamp-out-of-tolerance')
461
+ }
462
+ const key = await resolveSecret(secret, input.c)
463
+ const method = input.c.req.method.toUpperCase()
464
+ for (const candidate of await signedUrls(url, input.c)) {
465
+ const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`
466
+ if (await hmacVerify(key, payload, bytes)) return true
467
+ }
468
+ return reject(input, 'mismatch')
469
+ },
470
+ async sign(rawBody, secret, options) {
471
+ if (!options?.url)
472
+ throw new Error('hono-webhooks: hubspot.sign needs a url')
473
+ const t = options.timestamp ?? Date.now()
474
+ const method = (options.method ?? 'POST').toUpperCase()
475
+ const signature = await hmacSign(
476
+ secret,
477
+ `${method}${hubspotUrl(options.url)}${rawBody}${t}`
478
+ )
479
+ return {
480
+ 'x-hubspot-signature-v3': toBase64(signature),
481
+ 'x-hubspot-request-timestamp': String(t)
482
+ }
483
+ }
484
+ }