@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/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +48 -0
- package/module.js +8 -0
- package/package.json +51 -10
- package/src/address.js +280 -0
- package/src/config.js +85 -0
- package/src/deliver.js +271 -0
- package/src/errors.js +107 -0
- package/src/job.js +49 -0
- package/src/module.js +368 -0
- package/src/secrets.js +240 -0
- package/src/signature.js +330 -0
- package/src/store/index.js +41 -0
- package/src/store/mongo.js +229 -0
- package/src/store/schema.js +230 -0
- package/src/store/sql.js +450 -0
- package/src/webhooks.js +1219 -0
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
|
+
};
|