@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 { describe, before, after, it } from 'node:test'
|
|
2
|
+
import { BotDataStorage, NoSuchValueError } from '../lib/botdatastorage.js'
|
|
3
|
+
import assert from 'node:assert'
|
|
4
|
+
import { Sequelize } from 'sequelize'
|
|
5
|
+
|
|
6
|
+
describe('BotDataStorage', async () => {
|
|
7
|
+
let connection = null
|
|
8
|
+
let storage = null
|
|
9
|
+
before(async () => {
|
|
10
|
+
connection = new Sequelize('sqlite::memory:', { logging: false })
|
|
11
|
+
await connection.authenticate()
|
|
12
|
+
})
|
|
13
|
+
after(async () => {
|
|
14
|
+
await connection.close()
|
|
15
|
+
})
|
|
16
|
+
it('can initialize', async () => {
|
|
17
|
+
storage = new BotDataStorage(connection)
|
|
18
|
+
await storage.initialize()
|
|
19
|
+
})
|
|
20
|
+
it('can set a value', async () => {
|
|
21
|
+
await storage.set('test', 'key1', 'value1')
|
|
22
|
+
})
|
|
23
|
+
it('can get a value', async () => {
|
|
24
|
+
const value = await storage.get('test', 'key1')
|
|
25
|
+
assert.equal(value, 'value1')
|
|
26
|
+
})
|
|
27
|
+
it('knows if a value exists', async () => {
|
|
28
|
+
const flag = await storage.has('test', 'key1')
|
|
29
|
+
assert.ok(flag)
|
|
30
|
+
})
|
|
31
|
+
it('knows if a value does not exist', async () => {
|
|
32
|
+
const flag = await storage.has('test', 'nonexistent1')
|
|
33
|
+
assert.ok(!flag)
|
|
34
|
+
})
|
|
35
|
+
it('raises an error on a non-existent value', async () => {
|
|
36
|
+
try {
|
|
37
|
+
await storage.get('test', 'nonexistent2')
|
|
38
|
+
assert.fail('Did not raise an exception getting a nonexistent key')
|
|
39
|
+
} catch (e) {
|
|
40
|
+
assert.ok(e instanceof NoSuchValueError)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
it('can delete a value', async () => {
|
|
44
|
+
await storage.delete('test', 'key1')
|
|
45
|
+
})
|
|
46
|
+
it('knows if a value has been deleted', async () => {
|
|
47
|
+
const flag = await storage.has('test', 'key1')
|
|
48
|
+
assert.ok(!flag)
|
|
49
|
+
})
|
|
50
|
+
it('raises an error on a deleted value', async () => {
|
|
51
|
+
try {
|
|
52
|
+
await storage.get('test', 'key1')
|
|
53
|
+
assert.fail('Did not raise an exception getting a deleted key')
|
|
54
|
+
} catch (e) {
|
|
55
|
+
assert.ok(e instanceof NoSuchValueError)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
it('stores different data at different keys for the same bot', async () => {
|
|
59
|
+
await storage.set('test', 'key2', 'value2')
|
|
60
|
+
await storage.set('test', 'key3', 'value3')
|
|
61
|
+
const value2 = await storage.get('test', 'key2')
|
|
62
|
+
const value3 = await storage.get('test', 'key3')
|
|
63
|
+
assert.notEqual(value2, value3)
|
|
64
|
+
})
|
|
65
|
+
it('stores different data at the same key for different bots', async () => {
|
|
66
|
+
await storage.set('test2', 'key4', 'value4')
|
|
67
|
+
await storage.set('test3', 'key4', 'value5')
|
|
68
|
+
const value4 = await storage.get('test2', 'key4')
|
|
69
|
+
const value5 = await storage.get('test3', 'key4')
|
|
70
|
+
assert.notEqual(value4, value5)
|
|
71
|
+
})
|
|
72
|
+
it('can store numbers', async () => {
|
|
73
|
+
await storage.set('test', 'numberkey1', 23)
|
|
74
|
+
const value = await storage.get('test', 'numberkey1')
|
|
75
|
+
assert.equal(value, 23)
|
|
76
|
+
})
|
|
77
|
+
it('can store arrays', async () => {
|
|
78
|
+
await storage.set('test', 'arraykey1', [1, 2, 3])
|
|
79
|
+
const value = await storage.get('test', 'arraykey1')
|
|
80
|
+
assert.deepEqual(value, [1, 2, 3])
|
|
81
|
+
})
|
|
82
|
+
it('can store objects', async () => {
|
|
83
|
+
await storage.set('test', 'objectkey1', { a: 1, b: 2, c: 3 })
|
|
84
|
+
const value = await storage.get('test', 'objectkey1')
|
|
85
|
+
assert.deepEqual(value, { a: 1, b: 2, c: 3 })
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import Logger from 'pino'
|
|
4
|
+
import { Digester } from '../lib/digester.js'
|
|
5
|
+
|
|
6
|
+
describe('Digester', () => {
|
|
7
|
+
let digester = null
|
|
8
|
+
let logger = null
|
|
9
|
+
|
|
10
|
+
before(() => {
|
|
11
|
+
logger = new Logger({
|
|
12
|
+
level: 'silent'
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
logger = null
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('can initialize', async () => {
|
|
21
|
+
digester = new Digester(logger)
|
|
22
|
+
assert.ok(digester)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('can digest a string', async () => {
|
|
26
|
+
const text = 'Hello, world!'
|
|
27
|
+
const digest = await digester.digest(text)
|
|
28
|
+
assert.ok(digest)
|
|
29
|
+
assert.equal(digest, 'sha-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('can compare two equal digests', async () => {
|
|
33
|
+
const text = 'Hello, world!'
|
|
34
|
+
const digest1 = await digester.digest(text)
|
|
35
|
+
const digest2 = await digester.digest(text)
|
|
36
|
+
const result = await digester.equals(digest1, digest2)
|
|
37
|
+
assert.ok(result)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('can compare two different digests', async () => {
|
|
41
|
+
const text1 = 'Hello, world!'
|
|
42
|
+
const text2 = 'Hello, world!!'
|
|
43
|
+
const digest1 = await digester.digest(text1)
|
|
44
|
+
const digest2 = await digester.digest(text2)
|
|
45
|
+
const result = await digester.equals(digest1, digest2)
|
|
46
|
+
assert.ok(!result)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('can compare two digests that differ only in case of the algorithm', async () => {
|
|
50
|
+
const text = 'Hello, world!'
|
|
51
|
+
const digest1 = await digester.digest(text)
|
|
52
|
+
const digest2 = digest1.replace('sha-256', 'SHA-256')
|
|
53
|
+
const result = await digester.equals(digest1, digest2)
|
|
54
|
+
assert.ok(result)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import DoNothingBot from '../../lib/bots/donothing.js'
|
|
2
|
+
import OKBot from '../../lib/bots/ok.js'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
ok: new OKBot('ok'),
|
|
6
|
+
null: new DoNothingBot('null'),
|
|
7
|
+
test0: new DoNothingBot('test0'),
|
|
8
|
+
test1: new DoNothingBot('test1'),
|
|
9
|
+
test2: new DoNothingBot('test2'),
|
|
10
|
+
test3: new DoNothingBot('test3'),
|
|
11
|
+
test4: new DoNothingBot('test4'),
|
|
12
|
+
test5: new DoNothingBot('test5'),
|
|
13
|
+
test6: new DoNothingBot('test6'),
|
|
14
|
+
test7: new DoNothingBot('test7')
|
|
15
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, before, after, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { Sequelize } from 'sequelize'
|
|
4
|
+
import { KeyStorage } from '../lib/keystorage.js'
|
|
5
|
+
import { nockSetup, nockSignature, nockKeyRotate, getPublicKey, getPrivateKey, nockFormat } from './utils/nock.js'
|
|
6
|
+
import { HTTPSignature } from '../lib/httpsignature.js'
|
|
7
|
+
import Logger from 'pino'
|
|
8
|
+
import { Digester } from '../lib/digester.js'
|
|
9
|
+
|
|
10
|
+
describe('HTTPSignature', async () => {
|
|
11
|
+
const domain = 'activitypubbot.example'
|
|
12
|
+
const origin = `https://${domain}`
|
|
13
|
+
let connection = null
|
|
14
|
+
let httpSignature = null
|
|
15
|
+
let logger = null
|
|
16
|
+
let digester = null
|
|
17
|
+
before(async () => {
|
|
18
|
+
logger = Logger({
|
|
19
|
+
level: 'silent'
|
|
20
|
+
})
|
|
21
|
+
connection = new Sequelize('sqlite::memory:', { logging: false })
|
|
22
|
+
await connection.authenticate()
|
|
23
|
+
const keyStorage = new KeyStorage(connection, logger)
|
|
24
|
+
await keyStorage.initialize()
|
|
25
|
+
nockSetup('social.example')
|
|
26
|
+
digester = new Digester(logger)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
after(async () => {
|
|
30
|
+
await connection.close()
|
|
31
|
+
digester = null
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('can initialize', async () => {
|
|
35
|
+
httpSignature = new HTTPSignature(logger)
|
|
36
|
+
assert.ok(httpSignature)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('can validate a signature', async () => {
|
|
40
|
+
const username = 'test'
|
|
41
|
+
const date = new Date().toUTCString()
|
|
42
|
+
const signature = await nockSignature({
|
|
43
|
+
url: `${origin}/user/ok/outbox`,
|
|
44
|
+
date,
|
|
45
|
+
username
|
|
46
|
+
})
|
|
47
|
+
const headers = {
|
|
48
|
+
date,
|
|
49
|
+
signature,
|
|
50
|
+
host: URL.parse(origin).host
|
|
51
|
+
}
|
|
52
|
+
const publicKeyPem = await getPublicKey(username)
|
|
53
|
+
const method = 'GET'
|
|
54
|
+
const path = '/user/ok/outbox'
|
|
55
|
+
const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers)
|
|
56
|
+
assert.ok(result)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('can validate a signature on an URL with parameters', async () => {
|
|
60
|
+
const username = 'test'
|
|
61
|
+
const lname = 'ok'
|
|
62
|
+
const date = new Date().toUTCString()
|
|
63
|
+
const signature = await nockSignature({
|
|
64
|
+
url: `${origin}/.well-known/webfinger?resource=acct:${lname}@${domain}`,
|
|
65
|
+
date,
|
|
66
|
+
username
|
|
67
|
+
})
|
|
68
|
+
const headers = {
|
|
69
|
+
date,
|
|
70
|
+
signature,
|
|
71
|
+
host: URL.parse(origin).host
|
|
72
|
+
}
|
|
73
|
+
const publicKeyPem = await getPublicKey(username)
|
|
74
|
+
const method = 'GET'
|
|
75
|
+
const path = `/.well-known/webfinger?resource=acct:${lname}@${domain}`
|
|
76
|
+
const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers)
|
|
77
|
+
assert.ok(result)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('can handle key rotation', async () => {
|
|
81
|
+
const username = 'rotate'
|
|
82
|
+
const date = new Date().toUTCString()
|
|
83
|
+
const signature = await nockSignature({
|
|
84
|
+
url: `${origin}/user/ok/outbox`,
|
|
85
|
+
date,
|
|
86
|
+
username
|
|
87
|
+
})
|
|
88
|
+
const headers = {
|
|
89
|
+
date,
|
|
90
|
+
signature,
|
|
91
|
+
host: URL.parse(origin).host
|
|
92
|
+
}
|
|
93
|
+
const publicKeyPem = await getPublicKey(username)
|
|
94
|
+
const method = 'GET'
|
|
95
|
+
const path = '/user/ok/outbox'
|
|
96
|
+
await httpSignature.validate(publicKeyPem, signature, method, path, headers)
|
|
97
|
+
await nockKeyRotate(username)
|
|
98
|
+
const signature2 = await nockSignature({
|
|
99
|
+
url: `${origin}/user/ok/outbox`,
|
|
100
|
+
date,
|
|
101
|
+
username
|
|
102
|
+
})
|
|
103
|
+
const headers2 = {
|
|
104
|
+
date,
|
|
105
|
+
signature,
|
|
106
|
+
host: URL.parse(origin).host
|
|
107
|
+
}
|
|
108
|
+
const publicKeyPem2 = await getPublicKey(username)
|
|
109
|
+
assert.notStrictEqual(publicKeyPem, publicKeyPem2)
|
|
110
|
+
const result2 = await httpSignature.validate(publicKeyPem2, signature2, method, path, headers2)
|
|
111
|
+
assert.ok(result2)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('can sign a GET request', async () => {
|
|
115
|
+
const date = new Date().toUTCString()
|
|
116
|
+
const headers = {
|
|
117
|
+
Date: date,
|
|
118
|
+
Host: URL.parse(origin).host,
|
|
119
|
+
'X-Unused-Header': 'test',
|
|
120
|
+
Accept: 'application/activity+json',
|
|
121
|
+
'User-Agent': 'activitypubbot-test/0.0.1'
|
|
122
|
+
}
|
|
123
|
+
const privateKey = await getPrivateKey('test')
|
|
124
|
+
const method = 'GET'
|
|
125
|
+
const url = nockFormat({ username: 'test', obj: 'outbox' })
|
|
126
|
+
const keyId = nockFormat({ username: 'test', key: true })
|
|
127
|
+
const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers })
|
|
128
|
+
assert.ok(signature)
|
|
129
|
+
assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('can sign a POST request', async () => {
|
|
133
|
+
const body = JSON.stringify({
|
|
134
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
135
|
+
type: 'Create',
|
|
136
|
+
actor: nockFormat({ username: 'test' }),
|
|
137
|
+
object: nockFormat({ username: 'test', obj: 'note', num: 1 })
|
|
138
|
+
})
|
|
139
|
+
const headers = {
|
|
140
|
+
date: new Date().toUTCString(),
|
|
141
|
+
host: URL.parse(origin).host,
|
|
142
|
+
digest: await digester.digest(body),
|
|
143
|
+
'content-type': 'application/activity+json',
|
|
144
|
+
'User-Agent': 'activitypubbot-test/0.0.1'
|
|
145
|
+
}
|
|
146
|
+
const privateKey = await getPrivateKey('test')
|
|
147
|
+
const method = 'POST'
|
|
148
|
+
const url = nockFormat({ username: 'test', obj: 'outbox' })
|
|
149
|
+
const keyId = nockFormat({ username: 'test', key: true })
|
|
150
|
+
const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers })
|
|
151
|
+
assert.ok(signature)
|
|
152
|
+
assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent content-type digest",signature=".*",algorithm="rsa-sha256"$/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('errors if required GET headers not present', async () => {
|
|
156
|
+
const date = new Date().toUTCString()
|
|
157
|
+
const headers = {
|
|
158
|
+
Date: date,
|
|
159
|
+
Host: URL.parse(origin).host,
|
|
160
|
+
'User-Agent': 'activitypubbot-test/0.0.1'
|
|
161
|
+
}
|
|
162
|
+
const privateKey = await getPrivateKey('test')
|
|
163
|
+
const method = 'GET'
|
|
164
|
+
const url = nockFormat({ username: 'test', obj: 'outbox' })
|
|
165
|
+
const keyId = nockFormat({ username: 'test', key: true })
|
|
166
|
+
try {
|
|
167
|
+
await httpSignature.sign({ privateKey, keyId, url, method, headers })
|
|
168
|
+
assert.fail('Expected error not thrown')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
assert.equal(err.name, 'Error')
|
|
171
|
+
assert.equal(err.message, 'Missing header: accept')
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('errors if required POST headers not present', async () => {
|
|
176
|
+
const body = JSON.stringify({
|
|
177
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
178
|
+
type: 'Create',
|
|
179
|
+
actor: nockFormat({ username: 'test' }),
|
|
180
|
+
object: nockFormat({ username: 'test', obj: 'note', num: 1 })
|
|
181
|
+
})
|
|
182
|
+
const headers = {
|
|
183
|
+
date: new Date().toUTCString(),
|
|
184
|
+
host: URL.parse(origin).host,
|
|
185
|
+
digest: await digester.digest(body),
|
|
186
|
+
'User-Agent': 'activitypubbot-test/0.0.1'
|
|
187
|
+
}
|
|
188
|
+
const privateKey = await getPrivateKey('test')
|
|
189
|
+
const method = 'POST'
|
|
190
|
+
const url = nockFormat({ username: 'test', obj: 'outbox' })
|
|
191
|
+
const keyId = nockFormat({ username: 'test', key: true })
|
|
192
|
+
try {
|
|
193
|
+
await httpSignature.sign({ privateKey, keyId, url, method, headers })
|
|
194
|
+
assert.fail('Expected error not thrown')
|
|
195
|
+
} catch (err) {
|
|
196
|
+
assert.equal(err.name, 'Error')
|
|
197
|
+
assert.equal(err.message, 'Missing header: content-type')
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
})
|