@evanp/activitypub-bot 0.38.4 → 0.39.1
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/lib/activitypubclient.js +113 -12
- package/lib/app.js +43 -19
- package/lib/errors.js +158 -0
- package/lib/httpmessagesignature.js +1 -1
- package/lib/httpsignature.js +11 -11
- package/lib/httpsignatureauthenticator.js +15 -15
- package/lib/migrations/008-signature-policy.js +16 -0
- package/lib/ratelimiter.js +1 -1
- package/lib/remoteobjectcache.js +1 -1
- package/lib/routes/collection.js +7 -7
- package/lib/routes/health.js +2 -2
- package/lib/routes/inbox.js +21 -14
- package/lib/routes/object.js +13 -13
- package/lib/routes/profile.js +2 -2
- package/lib/routes/proxy.js +5 -5
- package/lib/routes/sharedinbox.js +13 -9
- package/lib/routes/user.js +9 -9
- package/lib/routes/webfinger.js +9 -9
- package/lib/signaturepolicystorage.js +61 -0
- package/package.json +1 -2
- package/web/profile.html +12 -8
package/lib/activitypubclient.js
CHANGED
|
@@ -4,9 +4,9 @@ import path from 'node:path'
|
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
6
|
import fetch from 'node-fetch'
|
|
7
|
-
import createHttpError from 'http-errors'
|
|
8
7
|
|
|
9
8
|
import as2 from './activitystreams.js'
|
|
9
|
+
import { SignaturePolicyStorage } from './signaturepolicystorage.js'
|
|
10
10
|
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url)
|
|
12
12
|
const __dirname = path.dirname(__filename)
|
|
@@ -22,6 +22,32 @@ const COLLECTION_TYPES = [
|
|
|
22
22
|
`${NS}OrderedCollection`
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
function normalizeHeaders (headers) {
|
|
26
|
+
if (!headers) {
|
|
27
|
+
return headers
|
|
28
|
+
}
|
|
29
|
+
if (typeof headers.forEach === 'function') {
|
|
30
|
+
const result = {}
|
|
31
|
+
headers.forEach((value, key) => {
|
|
32
|
+
result[key] = value
|
|
33
|
+
})
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
return headers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ActivityPubClientError extends Error {
|
|
40
|
+
constructor (status, message, { url, method, headers, body } = {}) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = 'ActivityPubClientError'
|
|
43
|
+
this.status = status
|
|
44
|
+
this.url = url
|
|
45
|
+
this.method = method
|
|
46
|
+
this.headers = normalizeHeaders(headers)
|
|
47
|
+
this.body = body
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
export class ActivityPubClient {
|
|
26
52
|
static #githubUrl = 'https://github.com/evanp/activitypub-bot'
|
|
27
53
|
static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
|
|
@@ -37,8 +63,10 @@ export class ActivityPubClient {
|
|
|
37
63
|
#logger = null
|
|
38
64
|
#limiter
|
|
39
65
|
#cache
|
|
66
|
+
#messageSigner
|
|
67
|
+
#policyStorage
|
|
40
68
|
|
|
41
|
-
constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache) {
|
|
69
|
+
constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache, messageSigner, policyStorage) {
|
|
42
70
|
assert.strictEqual(typeof keyStorage, 'object')
|
|
43
71
|
assert.strictEqual(typeof urlFormatter, 'object')
|
|
44
72
|
assert.strictEqual(typeof signer, 'object')
|
|
@@ -46,6 +74,8 @@ export class ActivityPubClient {
|
|
|
46
74
|
assert.strictEqual(typeof logger, 'object')
|
|
47
75
|
assert.strictEqual(typeof limiter, 'object')
|
|
48
76
|
assert.strictEqual(typeof cache, 'object')
|
|
77
|
+
assert.strictEqual(typeof messageSigner, 'object')
|
|
78
|
+
assert.strictEqual(typeof policyStorage, 'object')
|
|
49
79
|
this.#keyStorage = keyStorage
|
|
50
80
|
this.#urlFormatter = urlFormatter
|
|
51
81
|
this.#signer = signer
|
|
@@ -53,6 +83,8 @@ export class ActivityPubClient {
|
|
|
53
83
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
54
84
|
this.#limiter = limiter
|
|
55
85
|
this.#cache = cache
|
|
86
|
+
this.#messageSigner = messageSigner
|
|
87
|
+
this.#policyStorage = policyStorage
|
|
56
88
|
}
|
|
57
89
|
|
|
58
90
|
async get (url, username = this.#urlFormatter.hostname) {
|
|
@@ -129,21 +161,47 @@ export class ActivityPubClient {
|
|
|
129
161
|
this.#logger.debug({ headers }, 'Sending headers')
|
|
130
162
|
const method = 'GET'
|
|
131
163
|
this.#logger.debug({ url: baseUrl }, 'Signing GET request')
|
|
164
|
+
let storedPolicy, lastPolicy
|
|
132
165
|
if (sign) {
|
|
133
|
-
|
|
166
|
+
storedPolicy = await this.#policyStorage.get(parsed.origin)
|
|
167
|
+
if (!storedPolicy || storedPolicy === SignaturePolicyStorage.RFC9421) {
|
|
168
|
+
lastPolicy = SignaturePolicyStorage.RFC9421
|
|
169
|
+
const sigHeaders = await this.#messageSign({ username, url: baseUrl, method, headers })
|
|
170
|
+
Object.assign(headers, sigHeaders || {})
|
|
171
|
+
} else if (storedPolicy === SignaturePolicyStorage.DRAFT_CAVAGE_12) {
|
|
172
|
+
lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
|
|
173
|
+
headers.signature =
|
|
134
174
|
await this.#sign({ username, url: baseUrl, method, headers })
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error(`Unexpected signature policy ${storedPolicy}`)
|
|
177
|
+
}
|
|
135
178
|
}
|
|
136
179
|
const hostname = parsed.hostname
|
|
137
180
|
this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
|
|
138
181
|
await this.#limiter.limit(hostname)
|
|
139
182
|
this.#logger.debug({ url: baseUrl }, 'Fetching with GET')
|
|
140
|
-
|
|
183
|
+
let res = await fetch(baseUrl,
|
|
141
184
|
{
|
|
142
185
|
method,
|
|
143
186
|
headers
|
|
144
187
|
}
|
|
145
188
|
)
|
|
146
189
|
this.#logger.debug({ hostname, status: res.status }, 'response received')
|
|
190
|
+
if ([401, 403].includes(res.status) &&
|
|
191
|
+
sign &&
|
|
192
|
+
!storedPolicy) {
|
|
193
|
+
lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
|
|
194
|
+
delete headers['signature-input']
|
|
195
|
+
headers.signature =
|
|
196
|
+
await this.#sign({ username, url: baseUrl, method, headers })
|
|
197
|
+
res = await fetch(baseUrl,
|
|
198
|
+
{
|
|
199
|
+
method,
|
|
200
|
+
headers
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
147
205
|
await this.#limiter.update(hostname, res.headers)
|
|
148
206
|
this.#logger.debug({ url }, 'Finished GET')
|
|
149
207
|
if (useCache && res.status === 304) {
|
|
@@ -155,10 +213,10 @@ export class ActivityPubClient {
|
|
|
155
213
|
{ status: res.status, body, url: baseUrl },
|
|
156
214
|
'Could not fetch url'
|
|
157
215
|
)
|
|
158
|
-
throw
|
|
216
|
+
throw new ActivityPubClientError(
|
|
159
217
|
res.status,
|
|
160
218
|
`Could not fetch ${baseUrl}`,
|
|
161
|
-
{ headers: res.headers }
|
|
219
|
+
{ url: baseUrl, method, headers: res.headers, body }
|
|
162
220
|
)
|
|
163
221
|
}
|
|
164
222
|
|
|
@@ -176,6 +234,10 @@ export class ActivityPubClient {
|
|
|
176
234
|
throw err
|
|
177
235
|
}
|
|
178
236
|
|
|
237
|
+
if (sign && !storedPolicy && lastPolicy) {
|
|
238
|
+
await this.#policyStorage.set(parsed.origin, lastPolicy)
|
|
239
|
+
}
|
|
240
|
+
|
|
179
241
|
await this.#cache.set(baseUrl, username, json, res.headers)
|
|
180
242
|
|
|
181
243
|
return json
|
|
@@ -206,6 +268,7 @@ export class ActivityPubClient {
|
|
|
206
268
|
assert.ok(username)
|
|
207
269
|
assert.equal(typeof username, 'string')
|
|
208
270
|
assert.ok(username !== '*')
|
|
271
|
+
const parsed = (new URL(url))
|
|
209
272
|
const json = await obj.export()
|
|
210
273
|
this.#fixupJson(json)
|
|
211
274
|
const body = JSON.stringify(json)
|
|
@@ -218,28 +281,56 @@ export class ActivityPubClient {
|
|
|
218
281
|
const method = 'POST'
|
|
219
282
|
assert.ok(headers)
|
|
220
283
|
this.#logger.debug({ url }, 'Signing POST')
|
|
221
|
-
|
|
222
|
-
const
|
|
284
|
+
let lastPolicy
|
|
285
|
+
const storedPolicy = await this.#policyStorage.get(parsed.origin)
|
|
286
|
+
if (!storedPolicy || storedPolicy === SignaturePolicyStorage.RFC9421) {
|
|
287
|
+
lastPolicy = SignaturePolicyStorage.RFC9421
|
|
288
|
+
const sigHeaders = await this.#messageSign({ username, url, method, headers })
|
|
289
|
+
Object.assign(headers, sigHeaders || {})
|
|
290
|
+
} else if (storedPolicy === SignaturePolicyStorage.DRAFT_CAVAGE_12) {
|
|
291
|
+
lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
|
|
292
|
+
headers.signature =
|
|
293
|
+
await this.#sign({ username, url, method, headers })
|
|
294
|
+
} else {
|
|
295
|
+
throw new Error(`Unexpected signature policy ${storedPolicy}`)
|
|
296
|
+
}
|
|
297
|
+
const hostname = parsed.hostname
|
|
223
298
|
this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
|
|
224
299
|
await this.#limiter.limit(hostname)
|
|
225
300
|
this.#logger.debug({ url }, 'Fetching POST')
|
|
226
|
-
|
|
301
|
+
let res = await fetch(url,
|
|
227
302
|
{
|
|
228
303
|
method,
|
|
229
304
|
headers,
|
|
230
305
|
body
|
|
231
306
|
}
|
|
232
307
|
)
|
|
308
|
+
if ([401, 403].includes(res.status) && !storedPolicy) {
|
|
309
|
+
lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
|
|
310
|
+
delete headers['signature-input']
|
|
311
|
+
headers.signature =
|
|
312
|
+
await this.#sign({ username, url, method, headers })
|
|
313
|
+
res = await fetch(url,
|
|
314
|
+
{
|
|
315
|
+
method,
|
|
316
|
+
headers,
|
|
317
|
+
body
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
}
|
|
233
321
|
this.#logger.debug({ hostname }, 'updating limiter')
|
|
234
322
|
await this.#limiter.update(hostname, res.headers)
|
|
235
323
|
this.#logger.debug({ url }, 'Done fetching POST')
|
|
236
324
|
if (res.status < 200 || res.status > 299) {
|
|
237
|
-
throw
|
|
325
|
+
throw new ActivityPubClientError(
|
|
238
326
|
res.status,
|
|
239
|
-
|
|
240
|
-
{ headers: res.headers }
|
|
327
|
+
`Could not post to ${url}`,
|
|
328
|
+
{ url, method, headers: res.headers }
|
|
241
329
|
)
|
|
242
330
|
}
|
|
331
|
+
if (!storedPolicy && lastPolicy) {
|
|
332
|
+
await this.#policyStorage.set(parsed.origin, lastPolicy)
|
|
333
|
+
}
|
|
243
334
|
}
|
|
244
335
|
|
|
245
336
|
async #sign ({ username, url, method, headers }) {
|
|
@@ -252,6 +343,16 @@ export class ActivityPubClient {
|
|
|
252
343
|
return this.#signer.sign({ privateKey, keyId, url, method, headers })
|
|
253
344
|
}
|
|
254
345
|
|
|
346
|
+
async #messageSign ({ username, url, method, headers }) {
|
|
347
|
+
assert.ok(url)
|
|
348
|
+
assert.ok(method)
|
|
349
|
+
assert.ok(headers)
|
|
350
|
+
assert.ok(username)
|
|
351
|
+
const privateKey = await this.#keyStorage.getPrivateKey(username)
|
|
352
|
+
const keyId = this.#urlFormatter.format({ username, type: 'publickey' })
|
|
353
|
+
return this.#messageSigner.sign({ privateKey, keyId, url, method, headers })
|
|
354
|
+
}
|
|
355
|
+
|
|
255
356
|
#isCollection (obj) {
|
|
256
357
|
return (Array.isArray(obj.type))
|
|
257
358
|
? obj.type.some(item => COLLECTION_TYPES.includes(item))
|
package/lib/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
2
|
import { resolve, dirname } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
4
5
|
|
|
5
6
|
import { Sequelize } from 'sequelize'
|
|
6
7
|
import express from 'express'
|
|
@@ -8,7 +9,7 @@ import Logger from 'pino'
|
|
|
8
9
|
import HTTPLogger from 'pino-http'
|
|
9
10
|
|
|
10
11
|
import { ActivityDistributor } from './activitydistributor.js'
|
|
11
|
-
import { ActivityPubClient } from './activitypubclient.js'
|
|
12
|
+
import { ActivityPubClient, ActivityPubClientError } from './activitypubclient.js'
|
|
12
13
|
import { ActorStorage } from './actorstorage.js'
|
|
13
14
|
import { BotDataStorage } from './botdatastorage.js'
|
|
14
15
|
import { KeyStorage } from './keystorage.js'
|
|
@@ -45,7 +46,7 @@ import { FanoutWorker } from './fanoutworker.js'
|
|
|
45
46
|
import { IntakeWorker } from './intakeworker.js'
|
|
46
47
|
import { RemoteObjectCache } from './remoteobjectcache.js'
|
|
47
48
|
import { HTTPMessageSignature } from './httpmessagesignature.js'
|
|
48
|
-
import {
|
|
49
|
+
import { SignaturePolicyStorage } from './signaturepolicystorage.js'
|
|
49
50
|
|
|
50
51
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
51
52
|
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
@@ -77,7 +78,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
77
78
|
const objectStorage = new ObjectStorage(connection)
|
|
78
79
|
const limiter = new RateLimiter(connection, logger)
|
|
79
80
|
const remoteObjectCache = new RemoteObjectCache(connection, logger)
|
|
80
|
-
const
|
|
81
|
+
const policyStore = new SignaturePolicyStorage(connection, logger)
|
|
82
|
+
const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache, messageSigner, policyStore)
|
|
81
83
|
const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
|
|
82
84
|
const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
|
|
83
85
|
const jobQueue = new JobQueue(connection, logger)
|
|
@@ -253,24 +255,46 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
253
255
|
|
|
254
256
|
app.use((err, req, res, next) => {
|
|
255
257
|
const { logger } = req.app.locals
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
258
|
+
if (err instanceof ActivityPubClientError) {
|
|
259
|
+
logger.error({
|
|
260
|
+
err,
|
|
261
|
+
status: err.status,
|
|
262
|
+
url: err.url,
|
|
263
|
+
method: err.method
|
|
264
|
+
}, 'ActivityPub client request failed')
|
|
265
|
+
res.status(500).type('application/problem+json').json({
|
|
266
|
+
type: 'about:blank',
|
|
267
|
+
title: 'Internal Server Error',
|
|
268
|
+
status: 500,
|
|
269
|
+
detail: 'Internal Server Error'
|
|
270
|
+
})
|
|
268
271
|
} else {
|
|
269
|
-
|
|
272
|
+
let status = 500
|
|
273
|
+
if (err.status) {
|
|
274
|
+
status = err.status
|
|
275
|
+
}
|
|
276
|
+
const title = (http.STATUS_CODES[status])
|
|
277
|
+
? http.STATUS_CODES[status]
|
|
278
|
+
: 'Unknown Status'
|
|
279
|
+
|
|
280
|
+
if (status >= 500 && status < 600) {
|
|
281
|
+
logger.error(err)
|
|
282
|
+
} else if (status >= 400 && status < 500) {
|
|
283
|
+
logger.warn(err)
|
|
284
|
+
} else {
|
|
285
|
+
logger.debug(err)
|
|
286
|
+
}
|
|
287
|
+
const type = err.type || 'about:blank'
|
|
288
|
+
const problemTitle = err.title || title
|
|
289
|
+
const extra = Object.fromEntries(
|
|
290
|
+
Object.entries(err).filter(
|
|
291
|
+
([k]) => !['status', 'type', 'title', 'detail', 'stack'].includes(k)
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
res.status(status)
|
|
295
|
+
res.type('application/problem+json')
|
|
296
|
+
res.json({ type, title: problemTitle, status, detail: err.message, ...extra })
|
|
270
297
|
}
|
|
271
|
-
res.status(status)
|
|
272
|
-
res.type('application/problem+json')
|
|
273
|
-
res.json({ type: 'about:blank', title, status, detail: err.message })
|
|
274
298
|
})
|
|
275
299
|
|
|
276
300
|
app.onIdle = async () => {
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
|
|
3
|
+
const FEP_C180 = 'https://w3id.org/fep/c180'
|
|
4
|
+
|
|
5
|
+
export class ProblemDetailsError extends Error {
|
|
6
|
+
constructor (status, detail, extra = {}) {
|
|
7
|
+
super(detail)
|
|
8
|
+
this.status = status
|
|
9
|
+
const { type, title, ...rest } = extra
|
|
10
|
+
this.type = type || 'about:blank'
|
|
11
|
+
this.title = title || http.STATUS_CODES[status] || 'Unknown Status'
|
|
12
|
+
this.detail = detail
|
|
13
|
+
Object.assign(this, rest)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class UnsupportedTypeError extends ProblemDetailsError {
|
|
18
|
+
constructor (detail, extra = {}) {
|
|
19
|
+
// Rename `type` to `objectType` to avoid conflict with the problem type URI field
|
|
20
|
+
const { type: objectType, ...rest } = extra
|
|
21
|
+
super(400, detail, {
|
|
22
|
+
type: `${FEP_C180}#unsupported-type`,
|
|
23
|
+
title: 'Unsupported type',
|
|
24
|
+
...rest,
|
|
25
|
+
...(objectType != null ? { objectType } : {})
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ObjectDoesNotExistError extends ProblemDetailsError {
|
|
31
|
+
constructor (detail, extra = {}) {
|
|
32
|
+
super(400, detail, {
|
|
33
|
+
type: `${FEP_C180}#object-does-not-exist`,
|
|
34
|
+
title: 'Object does not exist',
|
|
35
|
+
...extra
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DuplicateDeliveryError extends ProblemDetailsError {
|
|
41
|
+
constructor (detail, extra = {}) {
|
|
42
|
+
super(400, detail, {
|
|
43
|
+
type: `${FEP_C180}#duplicate-delivery`,
|
|
44
|
+
title: 'Duplicate delivery',
|
|
45
|
+
...extra
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class RedundantActivityError extends ProblemDetailsError {
|
|
51
|
+
constructor (detail, extra = {}) {
|
|
52
|
+
super(400, detail, {
|
|
53
|
+
type: `${FEP_C180}#redundant-activity`,
|
|
54
|
+
title: 'Redundant activity',
|
|
55
|
+
...extra
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ApprovalRequiredError extends ProblemDetailsError {
|
|
61
|
+
constructor (detail, extra = {}) {
|
|
62
|
+
super(202, detail, {
|
|
63
|
+
type: `${FEP_C180}#approval-required`,
|
|
64
|
+
title: 'Approval required',
|
|
65
|
+
...extra
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class NotAnActorError extends ProblemDetailsError {
|
|
71
|
+
constructor (detail, extra = {}) {
|
|
72
|
+
super(400, detail, {
|
|
73
|
+
type: `${FEP_C180}#not-an-actor`,
|
|
74
|
+
title: 'Not an actor',
|
|
75
|
+
...extra
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class PrincipalActorMismatchError extends ProblemDetailsError {
|
|
81
|
+
constructor (detail, extra = {}) {
|
|
82
|
+
super(400, detail, {
|
|
83
|
+
type: `${FEP_C180}#principal-actor-mismatch`,
|
|
84
|
+
title: 'Principal-actor mismatch',
|
|
85
|
+
...extra
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ActorNotAuthorizedError extends ProblemDetailsError {
|
|
91
|
+
constructor (detail, extra = {}) {
|
|
92
|
+
super(403, detail, {
|
|
93
|
+
type: `${FEP_C180}#actor-not-authorized`,
|
|
94
|
+
title: 'Actor not authorized',
|
|
95
|
+
...extra
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class PrincipalNotAuthorizedError extends ProblemDetailsError {
|
|
101
|
+
constructor (detail, extra = {}) {
|
|
102
|
+
super(403, detail, {
|
|
103
|
+
type: `${FEP_C180}#principal-not-authorized`,
|
|
104
|
+
title: 'Principal not authorized',
|
|
105
|
+
...extra
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class ClientNotAuthorizedError extends ProblemDetailsError {
|
|
111
|
+
constructor (detail, extra = {}) {
|
|
112
|
+
super(403, detail, {
|
|
113
|
+
type: `${FEP_C180}#client-not-authorized`,
|
|
114
|
+
title: 'Client not authorized',
|
|
115
|
+
...extra
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class UnsupportedMediaTypeError extends ProblemDetailsError {
|
|
121
|
+
constructor (detail, extra = {}) {
|
|
122
|
+
super(400, detail, {
|
|
123
|
+
type: `${FEP_C180}#unsupported-media-type`,
|
|
124
|
+
title: 'Unsupported media type',
|
|
125
|
+
...extra
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class MediaTooLargeError extends ProblemDetailsError {
|
|
131
|
+
constructor (detail, extra = {}) {
|
|
132
|
+
super(413, detail, {
|
|
133
|
+
type: `${FEP_C180}#media-too-large`,
|
|
134
|
+
title: 'Media too large',
|
|
135
|
+
...extra
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class NoApplicableAddresseesError extends ProblemDetailsError {
|
|
141
|
+
constructor (detail, extra = {}) {
|
|
142
|
+
super(400, detail, {
|
|
143
|
+
type: `${FEP_C180}#no-applicable-addressees`,
|
|
144
|
+
title: 'No applicable addressees',
|
|
145
|
+
...extra
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class RateLimitExceededError extends ProblemDetailsError {
|
|
151
|
+
constructor (detail, extra = {}) {
|
|
152
|
+
super(429, detail, {
|
|
153
|
+
type: `${FEP_C180}#rate-limit-exceeded`,
|
|
154
|
+
title: 'Rate limit exceeded',
|
|
155
|
+
...extra
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
package/lib/httpsignature.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import { ProblemDetailsError } from './errors.js'
|
|
5
5
|
|
|
6
6
|
export class HTTPSignature {
|
|
7
7
|
static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
|
|
@@ -13,48 +13,48 @@ export class HTTPSignature {
|
|
|
13
13
|
keyId (signature) {
|
|
14
14
|
const params = this.#parseSignatureHeader(signature)
|
|
15
15
|
if (!params.keyId) {
|
|
16
|
-
throw
|
|
16
|
+
throw new ProblemDetailsError(401, 'No keyId provided')
|
|
17
17
|
}
|
|
18
18
|
return params.keyId
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async validate (publicKeyPem, signature, method, path, headers) {
|
|
22
22
|
if (!signature) {
|
|
23
|
-
throw
|
|
23
|
+
throw new ProblemDetailsError(401, 'No signature provided')
|
|
24
24
|
}
|
|
25
25
|
if (!method) {
|
|
26
|
-
throw
|
|
26
|
+
throw new ProblemDetailsError(400, 'No HTTP method provided')
|
|
27
27
|
}
|
|
28
28
|
if (!path) {
|
|
29
|
-
throw
|
|
29
|
+
throw new ProblemDetailsError(400, 'No URL path provided')
|
|
30
30
|
}
|
|
31
31
|
if (!headers) {
|
|
32
|
-
throw
|
|
32
|
+
throw new ProblemDetailsError(400, 'No request headers provided')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const params = this.#parseSignatureHeader(signature)
|
|
36
36
|
|
|
37
37
|
const keyId = params.keyId
|
|
38
38
|
if (!keyId) {
|
|
39
|
-
throw
|
|
39
|
+
throw new ProblemDetailsError(401, 'No keyId provided')
|
|
40
40
|
}
|
|
41
41
|
this.#logger.debug({ keyId }, 'validating signature')
|
|
42
42
|
const algorithm = params.algorithm
|
|
43
43
|
if (!algorithm) {
|
|
44
|
-
throw
|
|
44
|
+
throw new ProblemDetailsError(401, 'No algorithm provided')
|
|
45
45
|
}
|
|
46
46
|
this.#logger.debug({ algorithm }, 'validating signature')
|
|
47
47
|
if (!(algorithm === 'rsa-sha256' || (algorithm === 'hs2019' && this.#isRSAKey(publicKeyPem)))) {
|
|
48
|
-
throw
|
|
48
|
+
throw new ProblemDetailsError(401, 'Only rsa-sha256 or hs2019 with RSA supported')
|
|
49
49
|
}
|
|
50
50
|
if (!params.headers) {
|
|
51
|
-
throw
|
|
51
|
+
throw new ProblemDetailsError(401, 'No headers provided')
|
|
52
52
|
}
|
|
53
53
|
const signedHeaders = params.headers.split(' ')
|
|
54
54
|
this.#logger.debug({ signedHeaders }, 'validating signature')
|
|
55
55
|
const signatureString = params.signature
|
|
56
56
|
if (!signatureString) {
|
|
57
|
-
throw
|
|
57
|
+
throw new ProblemDetailsError(401, 'No signature field provided in signature header')
|
|
58
58
|
}
|
|
59
59
|
this.#logger.debug({ signatureString }, 'validating signature')
|
|
60
60
|
const signingString = this.#signingString({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { ProblemDetailsError } from './errors.js'
|
|
4
4
|
|
|
5
5
|
import BotMaker from './botmaker.js'
|
|
6
6
|
|
|
@@ -77,21 +77,21 @@ export class HTTPSignatureAuthenticator {
|
|
|
77
77
|
this.#logger.debug({ reqId: req.id, signature }, 'Got signed request')
|
|
78
78
|
const date = req.get('Date')
|
|
79
79
|
if (!date) {
|
|
80
|
-
throw
|
|
80
|
+
throw new ProblemDetailsError(400, 'No date provided')
|
|
81
81
|
}
|
|
82
82
|
if (Math.abs(Date.parse(date) - Date.now()) >
|
|
83
83
|
HTTPSignatureAuthenticator.#maxDateDiff) {
|
|
84
|
-
throw
|
|
84
|
+
throw new ProblemDetailsError(400, 'Time skew too large')
|
|
85
85
|
}
|
|
86
86
|
if (req.rawBodyText && req.rawBodyText.length > 0) {
|
|
87
87
|
const digest = req.get('Digest')
|
|
88
88
|
if (!digest) {
|
|
89
|
-
throw
|
|
89
|
+
throw new ProblemDetailsError(400, 'No digest provided')
|
|
90
90
|
}
|
|
91
91
|
const calculated = await this.#digester.digest(req.rawBodyText)
|
|
92
92
|
if (!this.#digester.equals(digest, calculated)) {
|
|
93
93
|
this.#logger.debug({ reqId: req.id, calculated, digest }, 'Digest mismatch')
|
|
94
|
-
throw
|
|
94
|
+
throw new ProblemDetailsError(400, 'Digest mismatch')
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
const { method, headers } = req
|
|
@@ -100,7 +100,7 @@ export class HTTPSignatureAuthenticator {
|
|
|
100
100
|
this.#logger.debug({ reqId: req.id, keyId }, 'Signed with keyId')
|
|
101
101
|
const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
|
|
102
102
|
if (!ok) {
|
|
103
|
-
throw
|
|
103
|
+
throw new ProblemDetailsError(400, 'public key not found')
|
|
104
104
|
}
|
|
105
105
|
let owner = ok.owner
|
|
106
106
|
let publicKeyPem = ok.publicKeyPem
|
|
@@ -125,7 +125,7 @@ export class HTTPSignatureAuthenticator {
|
|
|
125
125
|
req.auth.subject = owner
|
|
126
126
|
return next()
|
|
127
127
|
} else {
|
|
128
|
-
throw
|
|
128
|
+
throw new ProblemDetailsError(401, 'Unauthorized')
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -137,25 +137,25 @@ export class HTTPSignatureAuthenticator {
|
|
|
137
137
|
const date = req.get('Date')
|
|
138
138
|
if (date && Math.abs(Date.parse(date) - Date.now()) >
|
|
139
139
|
HTTPSignatureAuthenticator.#maxDateDiff) {
|
|
140
|
-
throw
|
|
140
|
+
throw new ProblemDetailsError(400, 'Time skew too large')
|
|
141
141
|
}
|
|
142
142
|
if (req.rawBodyText && req.rawBodyText.length > 0) {
|
|
143
143
|
const digest = req.get('Content-Digest')
|
|
144
144
|
if (!digest) {
|
|
145
|
-
throw
|
|
145
|
+
throw new ProblemDetailsError(400, 'No digest provided')
|
|
146
146
|
}
|
|
147
147
|
const calculated = await this.#digester.contentDigest(req.rawBodyText)
|
|
148
148
|
if (!this.#digester.equals(digest, calculated)) {
|
|
149
149
|
this.#logger.debug({ reqId: req.id, calculated, digest }, 'Digest mismatch')
|
|
150
|
-
throw
|
|
150
|
+
throw new ProblemDetailsError(400, 'Digest mismatch')
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
const created = this.#messageSigner.created(signatureInput)
|
|
154
154
|
if (!created) {
|
|
155
|
-
throw
|
|
155
|
+
throw new ProblemDetailsError(400, 'No created timestamp provided')
|
|
156
156
|
}
|
|
157
157
|
if (Math.abs(Date.now() - created * 1000) > HTTPSignatureAuthenticator.#maxDateDiff) {
|
|
158
|
-
throw
|
|
158
|
+
throw new ProblemDetailsError(400, 'Time skew too large')
|
|
159
159
|
}
|
|
160
160
|
const { method, headers } = req
|
|
161
161
|
const { origin } = req.app.locals
|
|
@@ -163,12 +163,12 @@ export class HTTPSignatureAuthenticator {
|
|
|
163
163
|
|
|
164
164
|
const keyId = this.#messageSigner.keyId(signatureInput)
|
|
165
165
|
if (!keyId) {
|
|
166
|
-
throw
|
|
166
|
+
throw new ProblemDetailsError(400, 'no public key provided')
|
|
167
167
|
}
|
|
168
168
|
this.#logger.debug({ reqId: req.id, keyId }, 'Signed with keyId')
|
|
169
169
|
const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
|
|
170
170
|
if (!ok) {
|
|
171
|
-
throw
|
|
171
|
+
throw new ProblemDetailsError(400, 'public key not found')
|
|
172
172
|
}
|
|
173
173
|
let owner = ok.owner
|
|
174
174
|
let publicKeyPem = ok.publicKeyPem
|
|
@@ -193,7 +193,7 @@ export class HTTPSignatureAuthenticator {
|
|
|
193
193
|
req.auth.subject = owner
|
|
194
194
|
return next()
|
|
195
195
|
} else {
|
|
196
|
-
throw
|
|
196
|
+
throw new ProblemDetailsError(401, 'Unauthorized')
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const id = '008-signature-policy'
|
|
2
|
+
|
|
3
|
+
export async function up (connection, queryOptions = {}) {
|
|
4
|
+
await connection.query(`
|
|
5
|
+
CREATE TABLE signature_policy (
|
|
6
|
+
origin varchar(256) NOT NULL PRIMARY KEY,
|
|
7
|
+
policy varchar(32) NOT NULL,
|
|
8
|
+
expiry TIMESTAMP NOT NULL,
|
|
9
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
11
|
+
);
|
|
12
|
+
`, queryOptions)
|
|
13
|
+
await connection.query(`
|
|
14
|
+
CREATE INDEX signature_policy_expiry on signature_policy (expiry);
|
|
15
|
+
`, queryOptions)
|
|
16
|
+
}
|