@usehenri/webhooks 0.0.0 → 1.2.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/module.js ADDED
@@ -0,0 +1,368 @@
1
+ const BaseModule = require('@usehenri/core/module');
2
+
3
+ const debug = require('debug')('henri:webhooks');
4
+
5
+ const { Webhooks } = require('./webhooks');
6
+ const { definition } = require('./job');
7
+
8
+ /**
9
+ * Outbound webhooks: the henri module this package ships.
10
+ *
11
+ * `package.json` points at it with `"henri": { "module": "./module.js" }`,
12
+ * so an application that depends on `@usehenri/webhooks` has it in the boot
13
+ * as `henri.webhooks`, with nothing else to write. One that does not has no
14
+ * such module, and core carries none of this.
15
+ *
16
+ * ## Why a package, and not part of one that exists
17
+ *
18
+ * It could have been part of `@usehenri/jobs`, which it depends on. It is
19
+ * not, because the queue would then carry an HTTP client, a signing scheme,
20
+ * an SSRF address check, an endpoints table and a key at rest -- none of
21
+ * which a queue needs, and all of which an application that only wanted
22
+ * background work would have to read past in a security review. It could
23
+ * have been part of core, and core carries neither the queue nor the
24
+ * uploads for the same reason. So: a package, next to `@usehenri/jobs` and
25
+ * `@usehenri/uploads`, peer-depending on both core and the queue, following
26
+ * the precedent those two set.
27
+ *
28
+ * ## Where it sits in the boot
29
+ *
30
+ * Runlevel 4, after the queue: it registers the delivery job on
31
+ * `henri.jobs`, so `henri jobs` -- which boots to that level and binds no
32
+ * port -- has the definition and performs the deliveries. `henri.cache` is
33
+ * at 3 and is used, without being needed, to keep an endpoint lookup off the
34
+ * database on every event.
35
+ *
36
+ * An application that installed this package and has no running queue boots
37
+ * anyway: the endpoints can be registered and inspected, and the first
38
+ * `emit()` is what says the queue is missing. Failing the boot over it would
39
+ * be a worse trade -- a webhook is not what an application serves.
40
+ *
41
+ * @class WebhooksModule
42
+ * @extends {BaseModule}
43
+ */
44
+ class WebhooksModule extends BaseModule {
45
+ /**
46
+ * Creates an instance of WebhooksModule.
47
+ *
48
+ * @param {object} [henri=null] A henri instance
49
+ * @memberof WebhooksModule
50
+ */
51
+ constructor(henri = null) {
52
+ super();
53
+
54
+ this.reloadable = true;
55
+ this.needs = ['config', 'model'];
56
+ this.after = ['cache', 'jobs'];
57
+ this.runlevel = 4;
58
+ this.name = 'webhooks';
59
+ this.henri = henri;
60
+
61
+ this.webhooks = null;
62
+ this.enabled = false;
63
+
64
+ this.init = this.init.bind(this);
65
+ this.stop = this.stop.bind(this);
66
+ this.reload = this.reload.bind(this);
67
+ }
68
+
69
+ /**
70
+ * Module initialization
71
+ * Called after being loaded by Modules
72
+ *
73
+ * @async
74
+ * @returns {Promise<string>} The name of the module
75
+ * @throws when the endpoints table cannot be reached
76
+ * @memberof WebhooksModule
77
+ */
78
+ async init() {
79
+ const { config, pen } = this.henri;
80
+ const settings =
81
+ config && config.has && config.has('webhooks')
82
+ ? config.get('webhooks')
83
+ : {};
84
+
85
+ this.webhooks = new Webhooks(this.henri, {
86
+ agent: this.agent(),
87
+ config: settings || {},
88
+ });
89
+
90
+ try {
91
+ await this.webhooks.start();
92
+ } catch (error) {
93
+ pen.error('webhooks', 'unable to prepare the endpoints', error.message);
94
+ throw error;
95
+ }
96
+
97
+ this.enabled = true;
98
+ this.deliver();
99
+
100
+ const wanted = this.webhooks.config;
101
+ const said = [
102
+ `deliveries on the ${wanted.queue} queue`,
103
+ `${wanted.maxAttempts} attempts`,
104
+ ];
105
+
106
+ if (wanted.allowPrivate) {
107
+ said.push('private addresses are ALLOWED');
108
+ }
109
+
110
+ if (wanted.allowHttp) {
111
+ said.push('plaintext http is ALLOWED');
112
+ }
113
+
114
+ pen.info('webhooks', ...said);
115
+
116
+ return this.name;
117
+ }
118
+
119
+ /**
120
+ * The user agent the deliveries carry
121
+ *
122
+ * @returns {string} `henri-webhooks/<version>`
123
+ * @memberof WebhooksModule
124
+ */
125
+ agent() {
126
+ try {
127
+ return `henri-webhooks/${require('../package.json').version}`;
128
+ } catch (error) {
129
+ return 'henri-webhooks';
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Registers the delivery job on the queue
135
+ *
136
+ * An application that wants its own delivery job writes
137
+ * `app/jobs/henri/webhook.js`, and it wins: the queue refuses to replace a
138
+ * definition that came from a file, the way it does for `henri/mail`.
139
+ *
140
+ * @returns {boolean} Whether the job was registered
141
+ * @memberof WebhooksModule
142
+ */
143
+ deliver() {
144
+ const { jobs, pen } = this.henri;
145
+
146
+ if (!jobs || !jobs.enabled) {
147
+ pen.warn(
148
+ 'webhooks',
149
+ 'no running job queue: the endpoints can be managed, but nothing can be delivered.',
150
+ 'Install @usehenri/jobs, add a "jobs" block to the configuration and run `henri jobs`'
151
+ );
152
+
153
+ return false;
154
+ }
155
+
156
+ if (typeof jobs.define !== 'function') {
157
+ pen.warn(
158
+ 'webhooks',
159
+ 'this @usehenri/jobs is too old to take a job from a package: upgrade it'
160
+ );
161
+
162
+ return false;
163
+ }
164
+
165
+ const { definition: job, name } = definition(this.webhooks);
166
+
167
+ debug('registering the %s job', name);
168
+
169
+ return jobs.define(name, job);
170
+ }
171
+
172
+ /**
173
+ * Stops the module
174
+ *
175
+ * @async
176
+ * @returns {Promise<boolean>} true when there was something to stop
177
+ * @memberof WebhooksModule
178
+ */
179
+ async stop() {
180
+ if (!this.webhooks) {
181
+ return false;
182
+ }
183
+
184
+ await this.webhooks.stop();
185
+ this.enabled = false;
186
+
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * Reloads the module
192
+ *
193
+ * @async
194
+ * @returns {Promise<string>} Module name
195
+ * @memberof WebhooksModule
196
+ */
197
+ async reload() {
198
+ await this.stop();
199
+
200
+ this.webhooks = null;
201
+
202
+ return this.init();
203
+ }
204
+
205
+ /**
206
+ * The endpoints, or a readable error
207
+ *
208
+ * @returns {Webhooks} The endpoints
209
+ * @throws when the module never started
210
+ * @memberof WebhooksModule
211
+ */
212
+ ready() {
213
+ if (!this.webhooks) {
214
+ throw this.henri.pen.fatal(
215
+ 'webhooks',
216
+ `
217
+ The webhooks are not ready: the module did not start.`,
218
+ null,
219
+ null,
220
+ 'HENRI_WEBHOOK_NOT_STARTED'
221
+ );
222
+ }
223
+
224
+ return this.webhooks;
225
+ }
226
+
227
+ /**
228
+ * Registers an endpoint, and hands its secret over once
229
+ *
230
+ * @param {object} options `url`, `events`, `owner`, `description`,
231
+ * `headers`, `secret`
232
+ * @returns {Promise<object>} The endpoint, with `secret`
233
+ * @memberof WebhooksModule
234
+ */
235
+ register(options) {
236
+ return this.ready().register(options);
237
+ }
238
+
239
+ /**
240
+ * Sends an event to every endpoint subscribed to it
241
+ *
242
+ * @param {string} event The event name
243
+ * @param {*} [data] What the receivers get, under `data`
244
+ * @param {object} [options] `owner`, `wait`, `at`
245
+ * @returns {Promise<Array<object>>} The deliveries enqueued
246
+ * @memberof WebhooksModule
247
+ */
248
+ emit(event, data, options) {
249
+ return this.ready().emit(event, data, options);
250
+ }
251
+
252
+ /**
253
+ * Enqueues one delivery to one endpoint
254
+ *
255
+ * @param {string} id The endpoint id
256
+ * @param {string} event The event name
257
+ * @param {*} [data] What the receiver gets, under `data`
258
+ * @param {object} [options] `wait`, `at`
259
+ * @returns {Promise<object>} The delivery
260
+ * @memberof WebhooksModule
261
+ */
262
+ deliverTo(id, event, data, options) {
263
+ return this.ready().enqueue(id, event, data, options);
264
+ }
265
+
266
+ /**
267
+ * One endpoint
268
+ *
269
+ * @param {string} id The endpoint id
270
+ * @returns {Promise<?object>} The endpoint, or null
271
+ * @memberof WebhooksModule
272
+ */
273
+ endpoint(id) {
274
+ return this.ready().endpoint(id);
275
+ }
276
+
277
+ /**
278
+ * The endpoints
279
+ *
280
+ * @param {object} [filter] `owner`, `disabled`, `limit`, `offset`
281
+ * @returns {Promise<Array<object>>} The endpoints
282
+ * @memberof WebhooksModule
283
+ */
284
+ endpoints(filter) {
285
+ return this.ready().endpoints(filter);
286
+ }
287
+
288
+ /**
289
+ * The active secrets of an endpoint, in the clear
290
+ *
291
+ * @param {string} id The endpoint id
292
+ * @returns {Promise<Array<string>>} The secrets that still sign
293
+ * @memberof WebhooksModule
294
+ */
295
+ secrets(id) {
296
+ return this.ready().secrets(id);
297
+ }
298
+
299
+ /**
300
+ * Changes an endpoint
301
+ *
302
+ * @param {string} id The endpoint id
303
+ * @param {object} [changes] `url`, `events`, `description`, `headers`
304
+ * @returns {Promise<object>} The endpoint
305
+ * @memberof WebhooksModule
306
+ */
307
+ update(id, changes) {
308
+ return this.ready().update(id, changes);
309
+ }
310
+
311
+ /**
312
+ * Gives an endpoint a new secret
313
+ *
314
+ * @param {string} id The endpoint id
315
+ * @param {object} [options] `grace`, `secret`
316
+ * @returns {Promise<object>} The endpoint, with the new `secret`
317
+ * @memberof WebhooksModule
318
+ */
319
+ rotate(id, options) {
320
+ return this.ready().rotate(id, options);
321
+ }
322
+
323
+ /**
324
+ * Stops sending to an endpoint
325
+ *
326
+ * @param {string} id The endpoint id
327
+ * @param {object} [options] `reason`
328
+ * @returns {Promise<object>} The endpoint
329
+ * @memberof WebhooksModule
330
+ */
331
+ disable(id, options) {
332
+ return this.ready().disable(id, options);
333
+ }
334
+
335
+ /**
336
+ * Sends to an endpoint again
337
+ *
338
+ * @param {string} id The endpoint id
339
+ * @returns {Promise<object>} The endpoint
340
+ * @memberof WebhooksModule
341
+ */
342
+ enable(id) {
343
+ return this.ready().enable(id);
344
+ }
345
+
346
+ /**
347
+ * Forgets an endpoint for good
348
+ *
349
+ * @param {string} id The endpoint id
350
+ * @returns {Promise<boolean>} Whether there was one to remove
351
+ * @memberof WebhooksModule
352
+ */
353
+ remove(id) {
354
+ return this.ready().remove(id);
355
+ }
356
+
357
+ /**
358
+ * The endpoints and what the queue holds for them
359
+ *
360
+ * @returns {Promise<object>} `{ endpoints, queue, deliveries }`
361
+ * @memberof WebhooksModule
362
+ */
363
+ stats() {
364
+ return this.ready().stats();
365
+ }
366
+ }
367
+
368
+ module.exports = WebhooksModule;
package/src/secrets.js ADDED
@@ -0,0 +1,240 @@
1
+ const {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ hkdfSync,
6
+ randomBytes,
7
+ randomUUID,
8
+ } = require('crypto');
9
+
10
+ const { WebhookError } = require('./errors');
11
+ const { SCHEME, generate } = require('./signature');
12
+
13
+ /**
14
+ * The signing secrets of an endpoint, and what protects them at rest.
15
+ *
16
+ * A signing secret is a bearer credential in both directions: whoever holds
17
+ * it can forge a delivery the receiver will believe. It has to be readable
18
+ * -- henri signs with it on every attempt, so it cannot be hashed the way a
19
+ * password is -- which leaves encryption as the only thing that makes a
20
+ * stolen database dump less than a forged delivery.
21
+ *
22
+ * So a secret is sealed with a key derived from `config.secret` (HKDF-
23
+ * SHA256, one label, 32 bytes) and stored as AES-256-GCM. The row carries
24
+ * the first bytes of the key's digest, so a `HENRI_SECRET` that changed is
25
+ * reported as exactly that instead of failing to decrypt with a wall of
26
+ * noise. An application without a `secret` -- there is one as soon as it has
27
+ * users -- stores its secrets as they are, and the boot says so once.
28
+ *
29
+ * The consequence is worth writing down, because it is a real operational
30
+ * trap: **rotating `HENRI_SECRET` makes every stored webhook secret
31
+ * unreadable**. The fix is not to decrypt them, it is to rotate the
32
+ * endpoints' own secrets (`henri webhooks:rotate <id>`) and hand the new
33
+ * ones to the receivers. Rotate one, not the other, or rotate both in that
34
+ * order.
35
+ */
36
+
37
+ /** The label the endpoint key is derived under */
38
+ const LABEL = 'henri.webhooks.secret.v1';
39
+
40
+ /**
41
+ * How a sealed value announces itself, and what separates its parts.
42
+ *
43
+ * A colon, not a dot: base64 has no colon in it, and the marker itself
44
+ * carries a version so the format can change without guessing.
45
+ */
46
+ const SEALED = 'henri-webhooks-v1';
47
+
48
+ /** The bytes of the derived key */
49
+ const KEY_BYTES = 32;
50
+
51
+ /** The bytes of a GCM nonce */
52
+ const IV_BYTES = 12;
53
+
54
+ /** How much of the key's digest names it in a row */
55
+ const KEY_ID = 8;
56
+
57
+ /**
58
+ * The key an application seals its endpoint secrets with
59
+ *
60
+ * @param {?string} secret `config.secret`
61
+ * @returns {?object} `{ id, key }`, or null without a secret
62
+ */
63
+ const keyring = (secret) => {
64
+ if (!secret) {
65
+ return null;
66
+ }
67
+
68
+ const key = Buffer.from(
69
+ hkdfSync('sha256', String(secret), 'henri.webhooks', LABEL, KEY_BYTES)
70
+ );
71
+
72
+ return {
73
+ id: createHash('sha256').update(key).digest('hex').slice(0, KEY_ID),
74
+ key,
75
+ };
76
+ };
77
+
78
+ /**
79
+ * Whether a stored value is sealed
80
+ *
81
+ * @param {string} value The stored value
82
+ * @returns {boolean} Whether it was encrypted
83
+ */
84
+ const isSealed = (value) => String(value || '').startsWith(`${SEALED}:`);
85
+
86
+ /**
87
+ * Seals a secret for the database
88
+ *
89
+ * @param {string} value The secret
90
+ * @param {?object} keys The keyring, or null to store it as it is
91
+ * @returns {string} What goes in the row
92
+ */
93
+ const seal = (value, keys) => {
94
+ if (!keys) {
95
+ return value;
96
+ }
97
+
98
+ const iv = randomBytes(IV_BYTES);
99
+ const cipher = createCipheriv('aes-256-gcm', keys.key, iv);
100
+ const sealed = Buffer.concat([
101
+ cipher.update(String(value), 'utf8'),
102
+ cipher.final(),
103
+ ]);
104
+
105
+ return [
106
+ SEALED,
107
+ keys.id,
108
+ iv.toString('base64'),
109
+ cipher.getAuthTag().toString('base64'),
110
+ sealed.toString('base64'),
111
+ ].join(':');
112
+ };
113
+
114
+ /**
115
+ * Reads a secret back
116
+ *
117
+ * @param {string} value What the row holds
118
+ * @param {?object} keys The keyring
119
+ * @returns {string} The secret
120
+ * @throws {WebhookError} SECRET_UNREADABLE when the key no longer opens it
121
+ */
122
+ const open = (value, keys) => {
123
+ if (!isSealed(value)) {
124
+ return value;
125
+ }
126
+
127
+ const [, id, iv, tag, sealed] = String(value).split(':');
128
+
129
+ if (!keys) {
130
+ throw new WebhookError(
131
+ 'HENRI_WEBHOOK_SECRET_UNREADABLE',
132
+ '@usehenri/webhooks: this endpoint secret is encrypted and the application has no "secret" to open it with',
133
+ {
134
+ hint: 'Set HENRI_SECRET back to what it was when the endpoint was registered',
135
+ retryable: false,
136
+ }
137
+ );
138
+ }
139
+
140
+ if (id !== keys.id) {
141
+ throw new WebhookError(
142
+ 'HENRI_WEBHOOK_SECRET_UNREADABLE',
143
+ `@usehenri/webhooks: this endpoint secret was sealed with another key (${id}, not ${keys.id})`,
144
+ {
145
+ hint: 'HENRI_SECRET changed: put the old one back, or give the endpoint a new secret with `henri webhooks:rotate <id>` and hand it to the receiver',
146
+ retryable: false,
147
+ }
148
+ );
149
+ }
150
+
151
+ try {
152
+ const decipher = createDecipheriv(
153
+ 'aes-256-gcm',
154
+ keys.key,
155
+ Buffer.from(iv, 'base64')
156
+ );
157
+
158
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
159
+
160
+ return Buffer.concat([
161
+ decipher.update(Buffer.from(sealed, 'base64')),
162
+ decipher.final(),
163
+ ]).toString('utf8');
164
+ } catch (error) {
165
+ throw new WebhookError(
166
+ 'HENRI_WEBHOOK_SECRET_UNREADABLE',
167
+ '@usehenri/webhooks: this endpoint secret does not decrypt',
168
+ {
169
+ cause: error,
170
+ hint: 'The row was changed after it was written, or the key it was sealed with is gone: rotate the endpoint secret',
171
+ retryable: false,
172
+ }
173
+ );
174
+ }
175
+ };
176
+
177
+ /**
178
+ * A new secret record, ready to be stored
179
+ *
180
+ * @param {object} [options={}] `secret` (one of your own), `now`
181
+ * @returns {object} `{ id, key, scheme, createdAt, expiresAt }`
182
+ */
183
+ const fresh = (options = {}) => ({
184
+ createdAt: options.now || Date.now(),
185
+ expiresAt: null,
186
+ id: randomUUID(),
187
+ key: options.secret || generate(),
188
+ scheme: SCHEME,
189
+ });
190
+
191
+ /**
192
+ * The records that still sign, oldest expiry last
193
+ *
194
+ * @param {Array<object>} secrets The stored records
195
+ * @param {number} [now=Date.now()] The moment
196
+ * @returns {Array<object>} The records that have not expired
197
+ */
198
+ const active = (secrets, now = Date.now()) =>
199
+ (secrets || []).filter(
200
+ (record) => !record.expiresAt || record.expiresAt > now
201
+ );
202
+
203
+ /**
204
+ * A rotation: a new record first, the old ones expiring after a grace
205
+ *
206
+ * Every record that has not expired keeps signing until it does, so a
207
+ * receiver has the length of the grace to install the new secret without
208
+ * dropping a delivery. A grace of zero retires the old secrets at once,
209
+ * which is what a leak calls for.
210
+ *
211
+ * @param {Array<object>} secrets The stored records
212
+ * @param {object} [options={}] `grace` (ms), `secret`, `now`
213
+ * @returns {Array<object>} The records to store
214
+ */
215
+ const rotate = (secrets, options = {}) => {
216
+ const now = options.now || Date.now();
217
+ const grace = Math.max(0, Number(options.grace) || 0);
218
+ const next = fresh({ now, secret: options.secret });
219
+ const kept = active(secrets, now).map((record) => ({
220
+ ...record,
221
+ expiresAt: Math.min(record.expiresAt || Infinity, now + grace),
222
+ }));
223
+
224
+ return grace === 0 ? [next] : [next, ...kept];
225
+ };
226
+
227
+ module.exports = {
228
+ IV_BYTES,
229
+ KEY_BYTES,
230
+ KEY_ID,
231
+ LABEL,
232
+ SEALED,
233
+ active,
234
+ fresh,
235
+ isSealed,
236
+ keyring,
237
+ open,
238
+ rotate,
239
+ seal,
240
+ };