hono-webhooks 0.1.1 → 0.3.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,240 @@
1
+ import type { Context } from 'hono'
2
+ import type { Verify } from './index'
3
+
4
+ /** A signing secret, or a function reading it per request (e.g. from `c.env`). */
5
+ export type Secret = string | ((c: Context<any>) => string | Promise<string>)
6
+
7
+ export interface SignatureScheme<VerifyOptions = void, SignOptions = void> {
8
+ /** A `verify` function for `defineWebhook`. */
9
+ verify(secret: Secret, options?: VerifyOptions): Verify
10
+ /** The headers a provider would send with `rawBody`. For tests and the CLI. */
11
+ sign(
12
+ rawBody: string,
13
+ secret: string,
14
+ options?: SignOptions
15
+ ): Promise<Record<string, string>>
16
+ }
17
+
18
+ export interface HmacOptions {
19
+ /** Header carrying the signature, e.g. `x-hub-signature-256`. */
20
+ header: string
21
+ encoding: 'hex' | 'base64'
22
+ /** Text before the signature in the header, e.g. `sha256=`. */
23
+ prefix?: string
24
+ /** @default 'SHA-256' */
25
+ hash?: 'SHA-1' | 'SHA-256' | 'SHA-512'
26
+ }
27
+
28
+ const encoder = new TextEncoder()
29
+
30
+ /** A string secret is used as UTF-8; bytes are used as is. */
31
+ function hmacKey(secret: string | Uint8Array, hash: string) {
32
+ if (!secret.length) {
33
+ throw new Error('hono-webhooks: the signing secret is empty')
34
+ }
35
+ return crypto.subtle.importKey(
36
+ 'raw',
37
+ (typeof secret === 'string'
38
+ ? encoder.encode(secret)
39
+ : secret) as Uint8Array<ArrayBuffer>,
40
+ { name: 'HMAC', hash },
41
+ false,
42
+ ['sign', 'verify']
43
+ )
44
+ }
45
+
46
+ async function hmacSign(
47
+ secret: string | Uint8Array,
48
+ payload: string,
49
+ hash = 'SHA-256'
50
+ ) {
51
+ const key = await hmacKey(secret, hash)
52
+ return new Uint8Array(
53
+ await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
54
+ )
55
+ }
56
+
57
+ /** `crypto.subtle.verify` compares in constant time. */
58
+ async function hmacVerify(
59
+ secret: string | Uint8Array,
60
+ payload: string,
61
+ signature: Uint8Array,
62
+ hash = 'SHA-256'
63
+ ) {
64
+ const key = await hmacKey(secret, hash)
65
+ return crypto.subtle.verify(
66
+ 'HMAC',
67
+ key,
68
+ signature as Uint8Array<ArrayBuffer>,
69
+ encoder.encode(payload)
70
+ )
71
+ }
72
+
73
+ const toHex = (bytes: Uint8Array) =>
74
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
75
+
76
+ const toBase64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes))
77
+
78
+ function fromHex(hex: string) {
79
+ if (hex.length % 2 || /[^0-9a-f]/i.test(hex)) return undefined
80
+ return Uint8Array.from(hex.match(/../g) ?? [], (h) => Number.parseInt(h, 16))
81
+ }
82
+
83
+ function fromBase64(base64: string) {
84
+ try {
85
+ return Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0))
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ const resolveSecret = async (secret: Secret, c: Context) =>
92
+ typeof secret === 'function' ? secret(c) : secret
93
+
94
+ /**
95
+ * A scheme where one header carries an HMAC of the raw body. Covers most
96
+ * providers; `github` and `shopify` are built with it.
97
+ *
98
+ * @example
99
+ * const linear = hmac({ header: 'linear-signature', encoding: 'hex' })
100
+ */
101
+ export function hmac(options: HmacOptions): SignatureScheme {
102
+ const { header, encoding, prefix = '', hash = 'SHA-256' } = options
103
+ const name = header.toLowerCase()
104
+ 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,
117
+ signature,
118
+ hash
119
+ )
120
+ },
121
+ async sign(rawBody, secret) {
122
+ const signature = await hmacSign(secret, rawBody, hash)
123
+ return {
124
+ [name]: `${prefix}${encoding === 'hex' ? toHex(signature) : toBase64(signature)}`
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ /** GitHub: `x-hub-signature-256: sha256=<hex>`. */
131
+ export const github = hmac({
132
+ header: 'x-hub-signature-256',
133
+ encoding: 'hex',
134
+ prefix: 'sha256='
135
+ })
136
+
137
+ /** Shopify: `x-shopify-hmac-sha256: <base64>`. */
138
+ export const shopify = hmac({
139
+ header: 'x-shopify-hmac-sha256',
140
+ encoding: 'base64'
141
+ })
142
+
143
+ /**
144
+ * Stripe: `stripe-signature: t=<unix>,v1=<hex>`, signed over `<t>.<body>`.
145
+ * Rejects timestamps more than `toleranceSeconds` (default 300) away.
146
+ */
147
+ export const stripe: SignatureScheme<
148
+ { toleranceSeconds?: number },
149
+ { timestamp?: number }
150
+ > = {
151
+ verify:
152
+ (secret, { toleranceSeconds = 300 } = {}) =>
153
+ async ({ c, rawBody, headers }) => {
154
+ const parts = (headers['stripe-signature'] ?? '')
155
+ .split(',')
156
+ .map((part) => part.split('='))
157
+ // Signed over the timestamp exactly as sent.
158
+ 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
162
+ }
163
+ const key = await resolveSecret(secret, c)
164
+ for (const [k, v] of parts) {
165
+ const signature = k === 'v1' && v ? fromHex(v) : undefined
166
+ if (
167
+ signature &&
168
+ (await hmacVerify(key, `${timestamp}.${rawBody}`, signature))
169
+ ) {
170
+ return true
171
+ }
172
+ }
173
+ return false
174
+ },
175
+ async sign(rawBody, secret, { timestamp } = {}) {
176
+ const t = timestamp ?? Math.floor(Date.now() / 1000)
177
+ const signature = await hmacSign(secret, `${t}.${rawBody}`)
178
+ return { 'stripe-signature': `t=${t},v1=${toHex(signature)}` }
179
+ }
180
+ }
181
+
182
+ /** Svix secrets are `whsec_` followed by the base64 key. */
183
+ function svixKey(secret: string) {
184
+ const key = fromBase64(secret.replace(/^whsec_/, ''))
185
+ if (!key?.length) {
186
+ throw new Error('hono-webhooks: the Svix secret must be whsec_<base64>')
187
+ }
188
+ return key
189
+ }
190
+
191
+ /**
192
+ * Svix and Standard Webhooks (Resend, Clerk, ...): `svix-id`,
193
+ * `svix-timestamp` and `svix-signature: v1,<base64> v1,<base64>`, signed over
194
+ * `<id>.<timestamp>.<body>` with the base64 key in a `whsec_` secret. The
195
+ * `webhook-*` header names of Standard Webhooks work too. Rejects timestamps
196
+ * more than `toleranceSeconds` (default 300) away.
197
+ */
198
+ export const svix: SignatureScheme<
199
+ { toleranceSeconds?: number },
200
+ { id?: string; timestamp?: number }
201
+ > = {
202
+ verify:
203
+ (secret, { toleranceSeconds = 300 } = {}) =>
204
+ async ({ c, rawBody, headers }) => {
205
+ const prefix = headers['svix-id'] ? 'svix' : 'webhook'
206
+ const id = headers[`${prefix}-id`]
207
+ const timestamp = headers[`${prefix}-timestamp`] ?? ''
208
+ 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
212
+ }
213
+ const key = svixKey(await resolveSecret(secret, c))
214
+ for (const entry of signatures.split(' ')) {
215
+ const [version, encoded] = entry.split(',')
216
+ const signature =
217
+ version === 'v1' && encoded ? fromBase64(encoded) : undefined
218
+ if (
219
+ signature &&
220
+ (await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature))
221
+ ) {
222
+ return true
223
+ }
224
+ }
225
+ return false
226
+ },
227
+ async sign(rawBody, secret, { id, timestamp } = {}) {
228
+ const msgId = id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`
229
+ const t = timestamp ?? Math.floor(Date.now() / 1000)
230
+ const signature = await hmacSign(
231
+ svixKey(secret),
232
+ `${msgId}.${t}.${rawBody}`
233
+ )
234
+ return {
235
+ 'svix-id': msgId,
236
+ 'svix-timestamp': String(t),
237
+ 'svix-signature': `v1,${toBase64(signature)}`
238
+ }
239
+ }
240
+ }