@evanp/activitypub-bot 0.8.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/.github/dependabot.yml +11 -0
- package/.github/workflows/main-docker.yml +45 -0
- package/.github/workflows/tag-docker.yml +54 -0
- package/Dockerfile +23 -0
- package/LICENSE +661 -0
- package/README.md +82 -0
- package/bots/index.js +7 -0
- package/docs/activitypub.bot.drawio +110 -0
- package/index.js +23 -0
- package/lib/activitydistributor.js +263 -0
- package/lib/activityhandler.js +999 -0
- package/lib/activitypubclient.js +126 -0
- package/lib/activitystreams.js +41 -0
- package/lib/actorstorage.js +300 -0
- package/lib/app.js +173 -0
- package/lib/authorizer.js +133 -0
- package/lib/bot.js +44 -0
- package/lib/botcontext.js +520 -0
- package/lib/botdatastorage.js +87 -0
- package/lib/bots/donothing.js +11 -0
- package/lib/bots/ok.js +41 -0
- package/lib/digester.js +23 -0
- package/lib/httpsignature.js +195 -0
- package/lib/httpsignatureauthenticator.js +81 -0
- package/lib/keystorage.js +113 -0
- package/lib/microsyntax.js +140 -0
- package/lib/objectcache.js +48 -0
- package/lib/objectstorage.js +319 -0
- package/lib/remotekeystorage.js +116 -0
- package/lib/routes/collection.js +92 -0
- package/lib/routes/health.js +24 -0
- package/lib/routes/inbox.js +83 -0
- package/lib/routes/object.js +69 -0
- package/lib/routes/server.js +47 -0
- package/lib/routes/user.js +63 -0
- package/lib/routes/webfinger.js +36 -0
- package/lib/urlformatter.js +97 -0
- package/package.json +51 -0
- package/tests/activitydistributor.test.js +606 -0
- package/tests/activityhandler.test.js +2185 -0
- package/tests/activitypubclient.test.js +225 -0
- package/tests/actorstorage.test.js +261 -0
- package/tests/app.test.js +17 -0
- package/tests/authorizer.test.js +306 -0
- package/tests/bot.donothing.test.js +30 -0
- package/tests/bot.ok.test.js +101 -0
- package/tests/botcontext.test.js +674 -0
- package/tests/botdatastorage.test.js +87 -0
- package/tests/digester.test.js +56 -0
- package/tests/fixtures/bots.js +15 -0
- package/tests/httpsignature.test.js +200 -0
- package/tests/httpsignatureauthenticator.test.js +463 -0
- package/tests/keystorage.test.js +89 -0
- package/tests/microsyntax.test.js +122 -0
- package/tests/objectcache.test.js +133 -0
- package/tests/objectstorage.test.js +148 -0
- package/tests/remotekeystorage.test.js +76 -0
- package/tests/routes.actor.test.js +207 -0
- package/tests/routes.collection.test.js +434 -0
- package/tests/routes.health.test.js +41 -0
- package/tests/routes.inbox.test.js +135 -0
- package/tests/routes.object.test.js +519 -0
- package/tests/routes.server.test.js +69 -0
- package/tests/routes.webfinger.test.js +41 -0
- package/tests/urlformatter.test.js +164 -0
- package/tests/utils/digest.js +7 -0
- package/tests/utils/nock.js +276 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
export class NoSuchValueError extends Error {
|
|
4
|
+
constructor (username, key) {
|
|
5
|
+
const message = `No such value ${key} for user ${username}`
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'NoSuchValueError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class BotDataStorage {
|
|
12
|
+
#connection = null
|
|
13
|
+
|
|
14
|
+
constructor (connection) {
|
|
15
|
+
this.#connection = connection
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async initialize () {
|
|
19
|
+
await this.#connection.query(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS botdata (
|
|
21
|
+
username VARCHAR(512) not null,
|
|
22
|
+
key VARCHAR(512) not null,
|
|
23
|
+
value TEXT not null,
|
|
24
|
+
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
25
|
+
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
26
|
+
PRIMARY KEY (username, key)
|
|
27
|
+
)
|
|
28
|
+
`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async terminate () {
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async set (username, key, value) {
|
|
36
|
+
assert.ok(this.#connection, 'BotDataStorage not initialized')
|
|
37
|
+
assert.ok(username, 'username is required')
|
|
38
|
+
assert.equal(typeof username, 'string', 'username must be a string')
|
|
39
|
+
assert.ok(key, 'key is required')
|
|
40
|
+
assert.equal(typeof key, 'string', 'key must be a string')
|
|
41
|
+
await this.#connection.query(`
|
|
42
|
+
INSERT INTO botdata (value, username, key) VALUES (?, ?, ?)
|
|
43
|
+
ON CONFLICT DO UPDATE SET value = EXCLUDED.value, updatedAt = CURRENT_TIMESTAMP`,
|
|
44
|
+
{ replacements: [JSON.stringify(value), username, key] }
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get (username, key) {
|
|
49
|
+
assert.ok(this.#connection, 'BotDataStorage not initialized')
|
|
50
|
+
assert.ok(username, 'username is required')
|
|
51
|
+
assert.equal(typeof username, 'string', 'username must be a string')
|
|
52
|
+
assert.ok(key, 'key is required')
|
|
53
|
+
assert.equal(typeof key, 'string', 'key must be a string')
|
|
54
|
+
const rows = await this.#connection.query(`
|
|
55
|
+
SELECT value FROM botdata WHERE username = ? AND key = ?`,
|
|
56
|
+
{ replacements: [username, key] }
|
|
57
|
+
)
|
|
58
|
+
if (rows[0].length === 0) {
|
|
59
|
+
throw new NoSuchValueError(username, key)
|
|
60
|
+
}
|
|
61
|
+
return JSON.parse(rows[0][0].value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async has (username, key) {
|
|
65
|
+
assert.ok(this.#connection, 'BotDataStorage not initialized')
|
|
66
|
+
assert.ok(username, 'username is required')
|
|
67
|
+
assert.equal(typeof username, 'string', 'username must be a string')
|
|
68
|
+
assert.ok(key, 'key is required')
|
|
69
|
+
assert.equal(typeof key, 'string', 'key must be a string')
|
|
70
|
+
const rows = await this.#connection.query(`
|
|
71
|
+
SELECT count(*) as count FROM botdata WHERE username = ? AND key = ?`,
|
|
72
|
+
{ replacements: [username, key] }
|
|
73
|
+
)
|
|
74
|
+
return (rows[0][0].count > 0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete (username, key) {
|
|
78
|
+
assert.ok(this.#connection, 'BotDataStorage not initialized')
|
|
79
|
+
assert.ok(username, 'username is required')
|
|
80
|
+
assert.equal(typeof username, 'string', 'username must be a string')
|
|
81
|
+
assert.ok(key, 'key is required')
|
|
82
|
+
await this.#connection.query(`
|
|
83
|
+
DELETE FROM botdata WHERE username = ? AND key = ?`,
|
|
84
|
+
{ replacements: [username, key] }
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/bots/ok.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Bot from '../bot.js'
|
|
2
|
+
|
|
3
|
+
export default class OKBot extends Bot {
|
|
4
|
+
get fullname () {
|
|
5
|
+
return 'OK Bot'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
get description () {
|
|
9
|
+
return 'A bot that says "OK" when mentioned.'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async onMention (object, activity) {
|
|
13
|
+
if (!await this.hasSeen(object)) {
|
|
14
|
+
const attributedTo =
|
|
15
|
+
object.attributedTo?.first.id ||
|
|
16
|
+
activity.actor?.first.id
|
|
17
|
+
const wf = await this._context.toWebfinger(attributedTo)
|
|
18
|
+
this._context.logger.info({ object: object.id, attributedTo, wf }, 'received mention')
|
|
19
|
+
const content = (wf) ? `@${wf} OK` : 'OK'
|
|
20
|
+
const reply = await this._context.sendReply(content, object)
|
|
21
|
+
this._context.logger.info({
|
|
22
|
+
reply: reply.id,
|
|
23
|
+
content,
|
|
24
|
+
inReplyTo: reply.inReplyTo.id
|
|
25
|
+
}, 'sent reply')
|
|
26
|
+
await this.setSeen(object)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async hasSeen (object) {
|
|
31
|
+
const id = object.id
|
|
32
|
+
const key = `seen:${id}`
|
|
33
|
+
return this._context.hasData(key)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async setSeen (object) {
|
|
37
|
+
const id = object.id
|
|
38
|
+
const key = `seen:${id}`
|
|
39
|
+
return this._context.setData(key, true)
|
|
40
|
+
}
|
|
41
|
+
}
|
package/lib/digester.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export class Digester {
|
|
4
|
+
#logger
|
|
5
|
+
constructor (logger) {
|
|
6
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async digest (body) {
|
|
10
|
+
const digest = crypto.createHash('sha256')
|
|
11
|
+
digest.update(body)
|
|
12
|
+
return `sha-256=${digest.digest('base64')}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
equals (digest1, digest2) {
|
|
16
|
+
const [alg1, hash1] = digest1.split('=', 2)
|
|
17
|
+
const [alg2, hash2] = digest2.split('=', 2)
|
|
18
|
+
if (alg1.toLowerCase() !== alg2.toLowerCase()) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return hash1 === hash2
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import createHttpError from 'http-errors'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import assert from 'node:assert'
|
|
4
|
+
|
|
5
|
+
export class HTTPSignature {
|
|
6
|
+
static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
|
|
7
|
+
#logger = null
|
|
8
|
+
constructor (logger) {
|
|
9
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
keyId (signature) {
|
|
13
|
+
const params = this.#parseSignatureHeader(signature)
|
|
14
|
+
if (!params.keyId) {
|
|
15
|
+
throw createHttpError(401, 'No keyId provided')
|
|
16
|
+
}
|
|
17
|
+
return params.keyId
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async validate (publicKeyPem, signature, method, path, headers) {
|
|
21
|
+
if (!signature) {
|
|
22
|
+
throw createHttpError(401, 'No signature provided')
|
|
23
|
+
}
|
|
24
|
+
if (!method) {
|
|
25
|
+
throw createHttpError(400, 'No HTTP method provided')
|
|
26
|
+
}
|
|
27
|
+
if (!path) {
|
|
28
|
+
throw createHttpError(400, 'No URL path provided')
|
|
29
|
+
}
|
|
30
|
+
if (!headers) {
|
|
31
|
+
throw createHttpError(400, 'No request headers provided')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const params = this.#parseSignatureHeader(signature)
|
|
35
|
+
|
|
36
|
+
const keyId = params.keyId
|
|
37
|
+
if (!keyId) {
|
|
38
|
+
throw createHttpError(401, 'No keyId provided')
|
|
39
|
+
}
|
|
40
|
+
this.#logger.debug({ keyId }, 'validating signature')
|
|
41
|
+
const algorithm = params.algorithm
|
|
42
|
+
if (!algorithm) {
|
|
43
|
+
throw createHttpError(401, 'No algorithm provided')
|
|
44
|
+
}
|
|
45
|
+
this.#logger.debug({ algorithm }, 'validating signature')
|
|
46
|
+
if (algorithm !== 'rsa-sha256') {
|
|
47
|
+
throw createHttpError(401, 'Only rsa-sha256 is supported')
|
|
48
|
+
}
|
|
49
|
+
if (!params.headers) {
|
|
50
|
+
throw createHttpError(401, 'No headers provided')
|
|
51
|
+
}
|
|
52
|
+
const signedHeaders = params.headers.split(' ')
|
|
53
|
+
this.#logger.debug({ signedHeaders }, 'validating signature')
|
|
54
|
+
const signatureString = params.signature
|
|
55
|
+
if (!signatureString) {
|
|
56
|
+
throw createHttpError(401, 'No signature field provided in signature header')
|
|
57
|
+
}
|
|
58
|
+
this.#logger.debug({ signatureString }, 'validating signature')
|
|
59
|
+
const signingString = this.#signingString({
|
|
60
|
+
method,
|
|
61
|
+
target: path,
|
|
62
|
+
host: headers.host,
|
|
63
|
+
headers,
|
|
64
|
+
headersList: signedHeaders
|
|
65
|
+
})
|
|
66
|
+
this.#logger.debug({ signingString }, 'validating signature')
|
|
67
|
+
return this.#verify(publicKeyPem, signingString, signatureString)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async sign ({ privateKey, keyId, url, method, headers }) {
|
|
71
|
+
assert.ok(privateKey)
|
|
72
|
+
assert.equal(typeof privateKey, 'string')
|
|
73
|
+
assert.ok(keyId)
|
|
74
|
+
assert.equal(typeof keyId, 'string')
|
|
75
|
+
assert.ok(url)
|
|
76
|
+
assert.equal(typeof url, 'string')
|
|
77
|
+
assert.ok(method)
|
|
78
|
+
assert.equal(typeof method, 'string')
|
|
79
|
+
assert.ok(headers)
|
|
80
|
+
assert.equal(typeof headers, 'object')
|
|
81
|
+
|
|
82
|
+
this.#logger.debug({ keyId, url, method, headers }, 'signing a request')
|
|
83
|
+
|
|
84
|
+
const algorithm = 'rsa-sha256'
|
|
85
|
+
const headersList = (method === 'POST')
|
|
86
|
+
? ['(request-target)', 'host', 'date', 'user-agent', 'content-type', 'digest']
|
|
87
|
+
: ['(request-target)', 'host', 'date', 'user-agent', 'accept']
|
|
88
|
+
|
|
89
|
+
this.#logger.debug({ algorithm, headersList }, 'signing a request')
|
|
90
|
+
|
|
91
|
+
const parsed = new URL(url)
|
|
92
|
+
const target = (parsed.search && parsed.search.length)
|
|
93
|
+
? `${parsed.pathname}${parsed.search}`
|
|
94
|
+
: `${parsed.pathname}`
|
|
95
|
+
const host = parsed.host
|
|
96
|
+
|
|
97
|
+
this.#logger.debug({ parsed, target, host }, 'signing a request')
|
|
98
|
+
|
|
99
|
+
const signingString = this.#signingString({
|
|
100
|
+
method,
|
|
101
|
+
host,
|
|
102
|
+
target,
|
|
103
|
+
headers,
|
|
104
|
+
headersList
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
this.#logger.debug({ signingString }, 'signing a request')
|
|
108
|
+
|
|
109
|
+
const signature = this.#signWithKey({
|
|
110
|
+
privateKey,
|
|
111
|
+
signingString,
|
|
112
|
+
algorithm
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
this.#logger.debug({ signature }, 'signed a request')
|
|
116
|
+
|
|
117
|
+
const signatureHeader = this.#signatureHeader({ keyId, headersList, signature, algorithm })
|
|
118
|
+
|
|
119
|
+
this.#logger.debug({ signatureHeader }, 'signed a request')
|
|
120
|
+
return signatureHeader
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#signWithKey ({ privateKey, signingString, algorithm }) {
|
|
124
|
+
if (algorithm !== 'rsa-sha256') {
|
|
125
|
+
throw new Error('Only rsa-sha256 is supported')
|
|
126
|
+
}
|
|
127
|
+
const signer = crypto.createSign('sha256')
|
|
128
|
+
signer.update(signingString)
|
|
129
|
+
const signature = signer.sign(privateKey).toString('base64')
|
|
130
|
+
signer.end()
|
|
131
|
+
|
|
132
|
+
return signature
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#signingString ({ method, host, target, headers, headersList }) {
|
|
136
|
+
const lines = []
|
|
137
|
+
const canon = {}
|
|
138
|
+
for (const key in headers) {
|
|
139
|
+
canon[key.toLowerCase()] = headers[key]
|
|
140
|
+
}
|
|
141
|
+
for (const headerName of headersList) {
|
|
142
|
+
if (headerName === '(request-target)') {
|
|
143
|
+
lines.push(`(request-target): ${method.toLowerCase()} ${target.trim()}`)
|
|
144
|
+
} else if (headerName === 'host') {
|
|
145
|
+
lines.push(`host: ${host.trim()}`)
|
|
146
|
+
} else if (headerName in canon) {
|
|
147
|
+
assert.ok(typeof canon[headerName] === 'string', `Header ${headerName} is not a string: ${canon[headerName]}`)
|
|
148
|
+
lines.push(`${headerName}: ${canon[headerName].trim()}`)
|
|
149
|
+
} else {
|
|
150
|
+
throw new Error(`Missing header: ${headerName}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return lines.join('\n')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#signatureHeader ({ keyId, headersList, signature, algorithm }) {
|
|
158
|
+
const components = {
|
|
159
|
+
keyId,
|
|
160
|
+
headers: headersList.join(' '),
|
|
161
|
+
signature,
|
|
162
|
+
algorithm
|
|
163
|
+
}
|
|
164
|
+
const properties = ['keyId', 'headers', 'signature', 'algorithm']
|
|
165
|
+
|
|
166
|
+
const pairs = []
|
|
167
|
+
for (const prop of properties) {
|
|
168
|
+
pairs.push(`${prop}="${this.#escape(components[prop])}"`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return pairs.join(',')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#escape (value) {
|
|
175
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#parseSignatureHeader (signature) {
|
|
179
|
+
const parts = signature.split(',')
|
|
180
|
+
const params = {}
|
|
181
|
+
for (const part of parts) {
|
|
182
|
+
const [key, value] = part.split('=')
|
|
183
|
+
params[key] = value.replace(/"/g, '')
|
|
184
|
+
}
|
|
185
|
+
return params
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#verify (publicKeyPem, signingString, signature) {
|
|
189
|
+
const verifier = crypto.createVerify('sha256')
|
|
190
|
+
verifier.update(signingString)
|
|
191
|
+
const isValid = verifier.verify(publicKeyPem, signature, 'base64')
|
|
192
|
+
verifier.end()
|
|
193
|
+
return isValid
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import createHttpError from 'http-errors'
|
|
2
|
+
|
|
3
|
+
export class HTTPSignatureAuthenticator {
|
|
4
|
+
static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
|
|
5
|
+
#remoteKeyStorage = null
|
|
6
|
+
#logger = null
|
|
7
|
+
#digester = null
|
|
8
|
+
#signer = null
|
|
9
|
+
constructor (remoteKeyStorage, signer, digester, logger) {
|
|
10
|
+
this.#remoteKeyStorage = remoteKeyStorage
|
|
11
|
+
this.#signer = signer
|
|
12
|
+
this.#digester = digester
|
|
13
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async authenticate (req, res, next) {
|
|
17
|
+
const signature = req.get('Signature')
|
|
18
|
+
if (!signature) {
|
|
19
|
+
// Just continue
|
|
20
|
+
return next()
|
|
21
|
+
}
|
|
22
|
+
const date = req.get('Date')
|
|
23
|
+
if (!date) {
|
|
24
|
+
return next(createHttpError(400, 'No date provided'))
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (Math.abs(Date.parse(date) - Date.now()) >
|
|
28
|
+
HTTPSignatureAuthenticator.#maxDateDiff) {
|
|
29
|
+
return next(createHttpError(400, 'Time skew too large'))
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// for date parsing errors
|
|
33
|
+
return next(err)
|
|
34
|
+
}
|
|
35
|
+
if (req.rawBodyText && req.rawBodyText.length > 0) {
|
|
36
|
+
const digest = req.get('Digest')
|
|
37
|
+
if (!digest) {
|
|
38
|
+
return next(createHttpError(400, 'No digest provided'))
|
|
39
|
+
}
|
|
40
|
+
const calculated = await this.#digester.digest(req.rawBodyText)
|
|
41
|
+
if (!this.#digester.equals(digest, calculated)) {
|
|
42
|
+
this.#logger.debug(`calculated: ${calculated} digest: ${digest}`)
|
|
43
|
+
return next(createHttpError(400, 'Digest mismatch'))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const { method, headers } = req
|
|
47
|
+
const originalUrl = req.originalUrl
|
|
48
|
+
this.#logger.debug({ originalUrl }, 'original URL')
|
|
49
|
+
try {
|
|
50
|
+
const keyId = this.#signer.keyId(signature)
|
|
51
|
+
const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
|
|
52
|
+
let owner = ok.owner
|
|
53
|
+
let publicKeyPem = ok.publicKeyPem
|
|
54
|
+
let result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
|
|
55
|
+
this.#logger.debug(`First validation result: ${result}`)
|
|
56
|
+
if (!result) {
|
|
57
|
+
// May be key rotation. Try again with uncached key
|
|
58
|
+
const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false)
|
|
59
|
+
if (ok2.publicKeyPem === ok.publicKeyPem) {
|
|
60
|
+
this.#logger.debug('same keys')
|
|
61
|
+
} else {
|
|
62
|
+
this.#logger.debug('different keys')
|
|
63
|
+
owner = ok2.owner
|
|
64
|
+
publicKeyPem = ok2.publicKeyPem
|
|
65
|
+
result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
|
|
66
|
+
this.#logger.debug(`Validation result: ${result}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (result) {
|
|
70
|
+
this.#logger.debug(`Signature valid for ${keyId}`)
|
|
71
|
+
req.auth = req.auth || {}
|
|
72
|
+
req.auth.subject = owner
|
|
73
|
+
return next()
|
|
74
|
+
} else {
|
|
75
|
+
return next(createHttpError(401, 'Unauthorized'))
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return next(err)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { promisify } from 'util'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import HumanHasher from 'humanhash'
|
|
4
|
+
import assert from 'node:assert'
|
|
5
|
+
|
|
6
|
+
const generateKeyPair = promisify(crypto.generateKeyPair)
|
|
7
|
+
|
|
8
|
+
export class KeyStorage {
|
|
9
|
+
#connection = null
|
|
10
|
+
#logger = null
|
|
11
|
+
#hasher = null
|
|
12
|
+
constructor (connection, logger) {
|
|
13
|
+
assert.ok(connection, 'connection is required')
|
|
14
|
+
assert.ok(logger, 'logger is required')
|
|
15
|
+
this.#connection = connection
|
|
16
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
17
|
+
this.#hasher = new HumanHasher()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async initialize () {
|
|
21
|
+
await this.#connection.query(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS new_keys (
|
|
23
|
+
username varchar(512) PRIMARY KEY,
|
|
24
|
+
public_key TEXT,
|
|
25
|
+
private_key TEXT
|
|
26
|
+
)
|
|
27
|
+
`)
|
|
28
|
+
try {
|
|
29
|
+
await this.#connection.query(`
|
|
30
|
+
INSERT OR IGNORE INTO new_keys (username, public_key, private_key)
|
|
31
|
+
SELECT bot_id, public_key, private_key
|
|
32
|
+
FROM keys
|
|
33
|
+
`)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
this.#logger.debug(
|
|
36
|
+
{ error, method: 'KeyStorage.initialize' },
|
|
37
|
+
'failed to copy keys to new_keys table')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getPublicKey (username) {
|
|
42
|
+
this.#logger.debug(
|
|
43
|
+
{ username, method: 'KeyStorage.getPublicKey' },
|
|
44
|
+
'getting public key for bot')
|
|
45
|
+
const [publicKey] = await this.#getKeys(username)
|
|
46
|
+
return publicKey
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getPrivateKey (username) {
|
|
50
|
+
this.#logger.debug(
|
|
51
|
+
{ username, method: 'KeyStorage.getPrivateKey' },
|
|
52
|
+
'getting private key for bot')
|
|
53
|
+
const [, privateKey] = await this.#getKeys(username)
|
|
54
|
+
return privateKey
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #getKeys (username) {
|
|
58
|
+
let privateKey
|
|
59
|
+
let publicKey
|
|
60
|
+
const [result] = await this.#connection.query(`
|
|
61
|
+
SELECT public_key, private_key FROM new_keys WHERE username = ?
|
|
62
|
+
`, { replacements: [username] })
|
|
63
|
+
if (result.length > 0) {
|
|
64
|
+
this.#logger.debug(
|
|
65
|
+
{ username, method: 'KeyStorage.#getKeys' },
|
|
66
|
+
'found key for bot in database')
|
|
67
|
+
publicKey = result[0].public_key
|
|
68
|
+
privateKey = result[0].private_key
|
|
69
|
+
} else {
|
|
70
|
+
this.#logger.debug(
|
|
71
|
+
{ username, method: 'KeyStorage.#getKeys' },
|
|
72
|
+
'no key for bot, generating new key'
|
|
73
|
+
);
|
|
74
|
+
[publicKey, privateKey] = await this.#newKeyPair(username)
|
|
75
|
+
this.#logger.debug(
|
|
76
|
+
{ username, method: 'KeyStorage.#getKeys' },
|
|
77
|
+
'saving new keypair to database'
|
|
78
|
+
)
|
|
79
|
+
await this.#saveKeyPair(username, publicKey, privateKey)
|
|
80
|
+
}
|
|
81
|
+
this.#logger.debug({
|
|
82
|
+
username,
|
|
83
|
+
method: 'KeyStorage.#getKeys',
|
|
84
|
+
publicKeyHash: this.#hasher.humanize(publicKey),
|
|
85
|
+
privateKeyHash: this.#hasher.humanize(privateKey)
|
|
86
|
+
})
|
|
87
|
+
return [publicKey, privateKey]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async #newKeyPair (username) {
|
|
91
|
+
const { publicKey, privateKey } = await generateKeyPair(
|
|
92
|
+
'rsa',
|
|
93
|
+
{
|
|
94
|
+
modulusLength: 2048,
|
|
95
|
+
privateKeyEncoding: {
|
|
96
|
+
type: 'pkcs8',
|
|
97
|
+
format: 'pem'
|
|
98
|
+
},
|
|
99
|
+
publicKeyEncoding: {
|
|
100
|
+
type: 'spki',
|
|
101
|
+
format: 'pem'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return [publicKey, privateKey]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async #saveKeyPair (username, publicKey, privateKey) {
|
|
109
|
+
await this.#connection.query(`
|
|
110
|
+
INSERT INTO new_keys (username, public_key, private_key) VALUES (?, ?, ?)
|
|
111
|
+
`, { replacements: [username, publicKey, privateKey] })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import fetch from 'node-fetch'
|
|
3
|
+
|
|
4
|
+
const AS2 = 'https://www.w3.org/ns/activitystreams#'
|
|
5
|
+
|
|
6
|
+
export class Transformer {
|
|
7
|
+
#tagNamespace = null
|
|
8
|
+
#client = null
|
|
9
|
+
constructor (tagNamespace, client) {
|
|
10
|
+
this.#tagNamespace = tagNamespace
|
|
11
|
+
this.#client = client
|
|
12
|
+
assert.ok(this.#tagNamespace)
|
|
13
|
+
assert.ok(this.#client)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async transform (text) {
|
|
17
|
+
let html = text
|
|
18
|
+
let tag = [];
|
|
19
|
+
({ html, tag } = this.#replaceUrls(html, tag));
|
|
20
|
+
({ html, tag } = this.#replaceHashtags(html, tag));
|
|
21
|
+
({ html, tag } = await this.#replaceMentions(html, tag))
|
|
22
|
+
html = `<p>${html}</p>`
|
|
23
|
+
return { html, tag }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#replaceUrls (html, tag) {
|
|
27
|
+
const url = /https?:\/\/\S+/g
|
|
28
|
+
const segments = this.#segment(html)
|
|
29
|
+
for (const i in segments) {
|
|
30
|
+
const segment = segments[i]
|
|
31
|
+
if (this.#isLink(segment)) continue
|
|
32
|
+
segments[i] = segment.replace(url, (match) => {
|
|
33
|
+
return `<a href="${match}">${match}</a>`
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return { html: segments.join(''), tag }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#replaceHashtags (html, tag) {
|
|
40
|
+
const hashtag = /#(\w+)/g
|
|
41
|
+
const segments = this.#segment(html)
|
|
42
|
+
for (const i in segments) {
|
|
43
|
+
const segment = segments[i]
|
|
44
|
+
if (this.#isLink(segment)) continue
|
|
45
|
+
segments[i] = segment.replace(hashtag, (match, name) => {
|
|
46
|
+
const href = this.#tagNamespace + name
|
|
47
|
+
tag.push({ type: AS2 + 'Hashtag', name: match, href })
|
|
48
|
+
return `<a href="${href}">${match}</a>`
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return { html: segments.join(''), tag }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #replaceMentions (html, tag) {
|
|
55
|
+
const self = this
|
|
56
|
+
const webfinger = /@[a-zA-Z0-9_]+([a-zA-Z0-9_.-]+[a-zA-Z0-9_]+)?@[a-zA-Z0-9_.-]+/g
|
|
57
|
+
const segments = this.#segment(html)
|
|
58
|
+
for (const i in segments) {
|
|
59
|
+
const segment = segments[i]
|
|
60
|
+
if (this.#isLink(segment)) continue
|
|
61
|
+
segments[i] = await this.#replaceAsync(segments[i], webfinger, async (match) => {
|
|
62
|
+
const href = await self.#homePage(match.slice(1))
|
|
63
|
+
if (!href) return match
|
|
64
|
+
tag.push({ type: 'Mention', name: match, href })
|
|
65
|
+
return `<a href="${href}">${match}</a>`
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return { html: segments.join(''), tag }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #homePage (webfinger) {
|
|
72
|
+
if (!this.#client) return null
|
|
73
|
+
const [username, domain] = webfinger.split('@')
|
|
74
|
+
const url = `https://${domain}/.well-known/webfinger?` +
|
|
75
|
+
`resource=acct:${username}@${domain}`
|
|
76
|
+
let json = null
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(url,
|
|
79
|
+
{ headers: { Accept: 'application/jrd+json' } })
|
|
80
|
+
if (response.status !== 200) return null
|
|
81
|
+
json = await response.json()
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
if (!json.links) return null
|
|
86
|
+
const link = json.links.find(
|
|
87
|
+
link => link.rel === 'self' &&
|
|
88
|
+
(link.type === 'application/activity+json' ||
|
|
89
|
+
link.type === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'))
|
|
90
|
+
if (!link) return null
|
|
91
|
+
const actorId = link.href
|
|
92
|
+
if (!actorId) return null
|
|
93
|
+
let actor = null
|
|
94
|
+
try {
|
|
95
|
+
actor = await this.#client.get(actorId)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(error)
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
if (!actor) return null
|
|
101
|
+
if (!actor.url) {
|
|
102
|
+
return actorId
|
|
103
|
+
}
|
|
104
|
+
for (const url of actor.url) {
|
|
105
|
+
if (url.type === AS2 + 'Link' &&
|
|
106
|
+
url.mediaType === 'text/html' &&
|
|
107
|
+
url.href) {
|
|
108
|
+
return url.href
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Fallback to first URL
|
|
112
|
+
if (actor.url.length === 1 && !actor.url.first.type) {
|
|
113
|
+
return actor.url.first.id
|
|
114
|
+
}
|
|
115
|
+
// Fallback even further to actor ID
|
|
116
|
+
return actorId
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#isLink (segment) {
|
|
120
|
+
return (segment.startsWith('<a>') || segment.startsWith('<a ')) && segment.endsWith('</a>')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#segment (html) {
|
|
124
|
+
return html.split(/(<[^>]+>[^<]+<\/[^>]+>)/)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #replaceAsync (str, regex, asyncFn) {
|
|
128
|
+
const promises = []
|
|
129
|
+
str.replace(regex, (match, ...args) => {
|
|
130
|
+
// Add a promise for each match
|
|
131
|
+
promises.push(asyncFn(match, ...args))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Wait for all async replacements to resolve
|
|
135
|
+
const replacements = await Promise.all(promises)
|
|
136
|
+
|
|
137
|
+
// Replace the matches with their respective replacements
|
|
138
|
+
return str.replace(regex, () => replacements.shift())
|
|
139
|
+
}
|
|
140
|
+
}
|