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.
- package/README.md +344 -91
- package/dist/cli.js +491 -20
- package/dist/index.d.ts +172 -18
- package/dist/index.js +123 -7
- package/dist/signatures.d.ts +53 -0
- package/dist/signatures.js +132 -0
- package/package.json +9 -3
- package/src/cli.ts +129 -25
- package/src/index.ts +366 -24
- package/src/signatures.ts +240 -0
|
@@ -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
|
+
}
|