@evanp/activitypub-bot 0.36.2 → 0.37.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/app.js CHANGED
@@ -44,6 +44,7 @@ import DoNothingBot from './bots/donothing.js'
44
44
  import { FanoutWorker } from './fanoutworker.js'
45
45
  import { IntakeWorker } from './intakeworker.js'
46
46
  import { RemoteObjectCache } from './remoteobjectcache.js'
47
+ import { HTTPMessageSignature } from './httpmessagesignature.js'
47
48
 
48
49
  const currentDir = dirname(fileURLToPath(import.meta.url))
49
50
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
@@ -66,6 +67,7 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
66
67
  await runMigrations(connection)
67
68
  const formatter = new UrlFormatter(origin)
68
69
  const signer = new HTTPSignature(logger)
70
+ const messageSigner = new HTTPMessageSignature(logger)
69
71
  const digester = new Digester(logger)
70
72
  const actorStorage = new ActorStorage(connection, formatter)
71
73
  const botDataStorage = new BotDataStorage(connection)
@@ -75,7 +77,7 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
75
77
  const remoteObjectCache = new RemoteObjectCache(connection, logger)
76
78
  const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache)
77
79
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
78
- const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
80
+ const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
79
81
  const jobQueue = new JobQueue(connection, logger)
80
82
  const distributor = new ActivityDistributor(
81
83
  client,
package/lib/digester.js CHANGED
@@ -12,6 +12,12 @@ export class Digester {
12
12
  return `sha-256=${digest.digest('base64')}`
13
13
  }
14
14
 
15
+ async contentDigest (body) {
16
+ const digest = crypto.createHash('sha256')
17
+ digest.update(body)
18
+ return `sha-256=:${digest.digest('base64')}:`
19
+ }
20
+
15
21
  equals (digest1, digest2) {
16
22
  const [alg1, hash1] = digest1.split('=', 2)
17
23
  const [alg2, hash2] = digest2.split('=', 2)
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert'
2
+ import crypto from 'node:crypto'
3
+
4
+ export class HTTPMessageSignature {
5
+ static #preferredAlgs = [
6
+ 'ecdsa-p384-sha384',
7
+ 'ecdsa-p256-sha256',
8
+ 'rsa-pss-sha512',
9
+ 'rsa-pss-sha256',
10
+ 'rsa-v1_5-sha256'
11
+ ]
12
+
13
+ #logger
14
+
15
+ constructor (logger) {
16
+ assert.ok(logger)
17
+ assert.strictEqual(typeof logger, 'object')
18
+ this.#logger = logger
19
+ }
20
+
21
+ keyId (signatureInput) {
22
+ assert.ok(signatureInput)
23
+ assert.strictEqual(typeof signatureInput, 'string')
24
+ const inputs = this.#parseSignatureInput(signatureInput)
25
+ const input = this.#bestInput(inputs)
26
+ return (input)
27
+ ? input.keyid
28
+ : null
29
+ }
30
+
31
+ created (signatureInput) {
32
+ assert.ok(signatureInput)
33
+ assert.strictEqual(typeof signatureInput, 'string')
34
+ const inputs = this.#parseSignatureInput(signatureInput)
35
+ const input = this.#bestInput(inputs)
36
+ return (input)
37
+ ? input.created
38
+ : null
39
+ }
40
+
41
+ async sign ({ privateKey, keyId, url, method, headers }) {
42
+ const parsed = new URL(url)
43
+
44
+ const signatureInput = []
45
+
46
+ signatureInput.push(['@method', method.toUpperCase()])
47
+ signatureInput.push(['@authority', parsed.host])
48
+ signatureInput.push(['@path', parsed.pathname])
49
+ if (parsed.search) {
50
+ signatureInput.push(['@query', parsed.search])
51
+ }
52
+
53
+ for (const name in headers) {
54
+ const lcname = name.toLowerCase()
55
+ signatureInput.push([lcname, headers[name]])
56
+ }
57
+
58
+ const created = Math.floor(Date.now() / 1000)
59
+ const componentList = signatureInput.map(([name]) => `"${name}"`).join(' ')
60
+ const signatureParams = `(${componentList});keyid="${keyId}";alg="rsa-v1_5-sha256";created=${created}`
61
+
62
+ signatureInput.push(['@signature-params', signatureParams])
63
+
64
+ const data = signatureInput.map(pair => `"${pair[0]}": ${pair[1]}`).join('\n')
65
+
66
+ const signer = crypto.createSign('sha256')
67
+ signer.update(data)
68
+ const signature = signer.sign(privateKey).toString('base64')
69
+ signer.end()
70
+
71
+ return {
72
+ 'signature-input': `sig1=${signatureParams}`,
73
+ signature: `sig1=:${signature}:`
74
+ }
75
+ }
76
+
77
+ async validate (publicKeyPem, signatureInput, signature, method, url, headers) {
78
+ const inputs = this.#parseSignatureInput(signatureInput)
79
+ const input = this.#bestInput(inputs)
80
+ if (!input) {
81
+ throw new Error('No input with supported algorithms')
82
+ }
83
+ const bytes = this.#sigBytes(signature, input.name)
84
+ if (!bytes) {
85
+ throw new Error('No input with supported algorithms')
86
+ }
87
+ const data = this.#inputData(input, method, url, headers)
88
+ const verifier = this.#getVerifier(input.alg)
89
+ verifier.update(data)
90
+ const options = this.#getVerifierOptions(input.alg)
91
+ return verifier.verify(publicKeyPem, bytes, options)
92
+ }
93
+
94
+ #parseSignatureInput (signatureInput) {
95
+ const SIG_RE = /(\w+)=(\([^)]*\))((?:;[^,]*)*)/g
96
+ const PARAM_RE = /;(\w+)=("(?:[^"\\]|\\.)*"|\d+)/g
97
+ const signatures = {}
98
+ for (const match of signatureInput.matchAll(SIG_RE)) {
99
+ const name = match[1]
100
+ const attrStr = `${match[2]}${match[3]}`
101
+ const params = match[2].slice(1, -1)
102
+ .split(' ')
103
+ .filter(s => s.length > 0)
104
+ .map(token => {
105
+ const m = token.match(/^"([^"]+)"(.*)$/)
106
+ if (!m) return token
107
+ return m[2] ? `${m[1]}${m[2]}` : m[1]
108
+ })
109
+ const sigvals = {}
110
+ for (const paramMatch of match[3].matchAll(PARAM_RE)) {
111
+ const k = paramMatch[1]
112
+ const v = paramMatch[2]
113
+ sigvals[k] = v.match(/^\d+$/) ? parseInt(v) : v.slice(1, -1)
114
+ }
115
+ signatures[name] = { name, params, attrStr, ...sigvals }
116
+ }
117
+ return signatures
118
+ }
119
+
120
+ #bestInput (inputs) {
121
+ for (const alg of HTTPMessageSignature.#preferredAlgs) {
122
+ const entry = Object.values(inputs).find(sig => sig.alg === alg)
123
+ if (entry) {
124
+ return entry
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ #inputData (input, method, url, headers) {
131
+ const signatureParams = []
132
+ const parsed = URL.parse(url)
133
+ for (const param of input.params) {
134
+ let value
135
+ switch (param) {
136
+ case '@method':
137
+ value = method.toUpperCase()
138
+ break
139
+ case '@authority':
140
+ value = headers.host
141
+ break
142
+ case '@path':
143
+ value = parsed.pathname
144
+ break
145
+ case '@query':
146
+ value = parsed.search
147
+ break
148
+ case '@target-uri':
149
+ value = url
150
+ break
151
+ case '@scheme':
152
+ value = parsed.protocol.slice(0, -1)
153
+ break
154
+ case '@request-target':
155
+ value = (parsed.search)
156
+ ? `${parsed.pathname}${parsed.search}`
157
+ : parsed.pathname
158
+ break
159
+ default:
160
+ if (param.startsWith('@query-param')) {
161
+ const nameMatch = param.match(/;name="([^"]+)"/)
162
+ if (!nameMatch) throw new Error('Missing name for @query-param')
163
+ const paramName = nameMatch[1]
164
+ signatureParams.push(['@query-param', parsed.searchParams.get(paramName), `name="${paramName}"`])
165
+ continue
166
+ }
167
+ if (param.length > 0 && param[0] === '@') {
168
+ throw new Error(`Unrecognized derived component ${param}`)
169
+ }
170
+ value = headers[param]
171
+ }
172
+ signatureParams.push([param, value])
173
+ }
174
+ signatureParams.push(['@signature-params', input.attrStr])
175
+ return signatureParams.map(
176
+ arr => arr.length === 3
177
+ ? `"${arr[0]}";${arr[2]}: ${arr[1]}`
178
+ : `"${arr[0]}": ${arr[1]}`
179
+ ).join('\n')
180
+ }
181
+
182
+ #getVerifier (alg) {
183
+ switch (alg) {
184
+ case 'ecdsa-p384-sha384':
185
+ return crypto.createVerify('sha384')
186
+ case 'ecdsa-p256-sha256':
187
+ return crypto.createVerify('sha256')
188
+ case 'rsa-pss-sha512':
189
+ return crypto.createVerify('sha512')
190
+ case 'rsa-pss-sha256':
191
+ return crypto.createVerify('sha256')
192
+ case 'rsa-v1_5-sha256':
193
+ return crypto.createVerify('sha256')
194
+ }
195
+ }
196
+
197
+ #getVerifierOptions (alg) {
198
+ switch (alg) {
199
+ case 'rsa-pss-sha512':
200
+ case 'rsa-pss-sha256':
201
+ return { padding: crypto.constants.RSA_PKCS1_PSS_PADDING }
202
+ default:
203
+ return { }
204
+ }
205
+ }
206
+
207
+ #sigBytes (sigHeader, name) {
208
+ const sigMatch = sigHeader.match(new RegExp(`${name}=:([^:]+):`))
209
+ if (!sigMatch) return false
210
+ return Buffer.from(sigMatch[1], 'base64')
211
+ }
212
+ }
@@ -1,27 +1,44 @@
1
+ import assert from 'node:assert'
2
+
1
3
  import createHttpError from 'http-errors'
2
4
 
3
5
  import BotMaker from './botmaker.js'
4
6
 
5
7
  export class HTTPSignatureAuthenticator {
6
8
  static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
7
- #remoteKeyStorage = null
8
- #logger = null
9
- #digester = null
10
- #signer = null
11
- constructor (remoteKeyStorage, signer, digester, logger) {
9
+ #remoteKeyStorage
10
+ #logger
11
+ #digester
12
+ #signer
13
+ #messageSigner
14
+
15
+ constructor (remoteKeyStorage, signer, messageSigner, digester, logger) {
16
+ assert.ok(remoteKeyStorage)
17
+ assert.strictEqual(typeof remoteKeyStorage, 'object')
18
+ assert.ok(signer)
19
+ assert.strictEqual(typeof signer, 'object')
20
+ assert.ok(messageSigner)
21
+ assert.strictEqual(typeof messageSigner, 'object')
22
+ assert.ok(digester)
23
+ assert.strictEqual(typeof digester, 'object')
24
+ assert.ok(logger)
25
+ assert.strictEqual(typeof logger, 'object')
12
26
  this.#remoteKeyStorage = remoteKeyStorage
13
27
  this.#signer = signer
28
+ this.#messageSigner = messageSigner
14
29
  this.#digester = digester
15
30
  this.#logger = logger.child({ class: this.constructor.name })
16
31
  }
17
32
 
18
33
  async authenticate (req, res, next) {
34
+ const { formatter, origin, bots } = req.app.locals
35
+
19
36
  const signature = req.get('Signature')
20
37
  if (!signature) {
21
38
  // Just continue
22
39
  return next()
23
40
  }
24
- const { formatter, origin, bots } = req.app.locals
41
+
25
42
  const originalUrl = req.originalUrl
26
43
  const fullUrl = `${origin}${originalUrl}`
27
44
  let parts
@@ -41,67 +58,133 @@ export class HTTPSignatureAuthenticator {
41
58
  return next()
42
59
  }
43
60
  }
61
+
62
+ const signatureInput = req.get('Signature-Input')
63
+
64
+ try {
65
+ if (signatureInput) {
66
+ await this.#authenticateMessageSignature(signature, signatureInput, originalUrl, req, res, next)
67
+ } else {
68
+ await this.#authenticateSignature(signature, originalUrl, req, res, next)
69
+ }
70
+ } catch (err) {
71
+ this.#logger.debug({ err }, 'Error authenticating key')
72
+ return next(err)
73
+ }
74
+ }
75
+
76
+ async #authenticateSignature (signature, originalUrl, req, res, next) {
44
77
  this.#logger.debug({ signature }, 'Got signed request')
45
78
  const date = req.get('Date')
46
79
  if (!date) {
47
- return next(createHttpError(400, 'No date provided'))
80
+ throw createHttpError(400, 'No date provided')
48
81
  }
49
- try {
50
- if (Math.abs(Date.parse(date) - Date.now()) >
82
+ if (Math.abs(Date.parse(date) - Date.now()) >
51
83
  HTTPSignatureAuthenticator.#maxDateDiff) {
52
- return next(createHttpError(400, 'Time skew too large'))
53
- }
54
- } catch (err) {
55
- // for date parsing errors
56
- return next(err)
84
+ throw createHttpError(400, 'Time skew too large')
57
85
  }
58
86
  if (req.rawBodyText && req.rawBodyText.length > 0) {
59
87
  const digest = req.get('Digest')
60
88
  if (!digest) {
61
- return next(createHttpError(400, 'No digest provided'))
89
+ throw createHttpError(400, 'No digest provided')
62
90
  }
63
91
  const calculated = await this.#digester.digest(req.rawBodyText)
64
92
  if (!this.#digester.equals(digest, calculated)) {
65
93
  this.#logger.debug(`calculated: ${calculated} digest: ${digest}`)
66
- return next(createHttpError(400, 'Digest mismatch'))
94
+ throw createHttpError(400, 'Digest mismatch')
67
95
  }
68
96
  }
69
97
  const { method, headers } = req
70
98
  this.#logger.debug({ originalUrl }, 'original URL')
71
- try {
72
- const keyId = this.#signer.keyId(signature)
73
- this.#logger.debug({ keyId }, 'Signed with keyId')
74
- const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
75
- if (!ok) {
76
- throw createHttpError(400, 'public key not found')
99
+ const keyId = this.#signer.keyId(signature)
100
+ this.#logger.debug({ keyId }, 'Signed with keyId')
101
+ const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
102
+ if (!ok) {
103
+ throw createHttpError(400, 'public key not found')
104
+ }
105
+ let owner = ok.owner
106
+ let publicKeyPem = ok.publicKeyPem
107
+ let result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
108
+ this.#logger.debug(`First validation result: ${result}`)
109
+ if (!result) {
110
+ // May be key rotation. Try again with uncached key
111
+ const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false)
112
+ if (ok2.publicKeyPem === ok.publicKeyPem) {
113
+ this.#logger.debug('same keys')
114
+ } else {
115
+ this.#logger.debug('different keys')
116
+ owner = ok2.owner
117
+ publicKeyPem = ok2.publicKeyPem
118
+ result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
119
+ this.#logger.debug(`Validation result: ${result}`)
77
120
  }
78
- let owner = ok.owner
79
- let publicKeyPem = ok.publicKeyPem
80
- let result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
81
- this.#logger.debug(`First validation result: ${result}`)
82
- if (!result) {
83
- // May be key rotation. Try again with uncached key
84
- const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false)
85
- if (ok2.publicKeyPem === ok.publicKeyPem) {
86
- this.#logger.debug('same keys')
87
- } else {
88
- this.#logger.debug('different keys')
89
- owner = ok2.owner
90
- publicKeyPem = ok2.publicKeyPem
91
- result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
92
- this.#logger.debug(`Validation result: ${result}`)
93
- }
121
+ }
122
+ if (result) {
123
+ this.#logger.debug(`Signature valid for ${keyId}`)
124
+ req.auth = req.auth || {}
125
+ req.auth.subject = owner
126
+ return next()
127
+ } else {
128
+ throw createHttpError(401, 'Unauthorized')
129
+ }
130
+ }
131
+
132
+ async #authenticateMessageSignature (signature, signatureInput, originalUrl, req, res, next) {
133
+ if (req.rawBodyText && req.rawBodyText.length > 0) {
134
+ const digest = req.get('Content-Digest')
135
+ if (!digest) {
136
+ throw createHttpError(400, 'No digest provided')
94
137
  }
95
- if (result) {
96
- this.#logger.debug(`Signature valid for ${keyId}`)
97
- req.auth = req.auth || {}
98
- req.auth.subject = owner
99
- return next()
138
+ const calculated = await this.#digester.contentDigest(req.rawBodyText)
139
+ if (!this.#digester.equals(digest, calculated)) {
140
+ this.#logger.debug(`calculated: ${calculated} digest: ${digest}`)
141
+ throw createHttpError(400, 'Digest mismatch')
142
+ }
143
+ }
144
+ const created = this.#messageSigner.created(signatureInput)
145
+ if (!created) {
146
+ throw createHttpError(400, 'No created timestamp provided')
147
+ }
148
+ if (Math.abs(Date.now() - created * 1000) > HTTPSignatureAuthenticator.#maxDateDiff) {
149
+ throw createHttpError(400, 'Time skew too large')
150
+ }
151
+ const { method, headers } = req
152
+ const { origin } = req.app.locals
153
+ const url = `${origin}${originalUrl}`
154
+
155
+ const keyId = this.#messageSigner.keyId(signatureInput)
156
+ if (!keyId) {
157
+ throw createHttpError(400, 'no public key provided')
158
+ }
159
+ this.#logger.debug({ keyId }, 'Signed with keyId')
160
+ const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
161
+ if (!ok) {
162
+ throw createHttpError(400, 'public key not found')
163
+ }
164
+ let owner = ok.owner
165
+ let publicKeyPem = ok.publicKeyPem
166
+ let result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, url, headers)
167
+ this.#logger.debug(`First validation result: ${result}`)
168
+ if (!result) {
169
+ // May be key rotation. Try again with uncached key
170
+ const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false)
171
+ if (ok2.publicKeyPem === ok.publicKeyPem) {
172
+ this.#logger.debug('same keys')
100
173
  } else {
101
- return next(createHttpError(401, 'Unauthorized'))
174
+ this.#logger.debug('different keys')
175
+ owner = ok2.owner
176
+ publicKeyPem = ok2.publicKeyPem
177
+ result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, url, headers)
178
+ this.#logger.debug(`Validation result: ${result}`)
102
179
  }
103
- } catch (err) {
104
- return next(err)
180
+ }
181
+ if (result) {
182
+ this.#logger.debug(`Signature valid for ${keyId}`)
183
+ req.auth = req.auth || {}
184
+ req.auth.subject = owner
185
+ return next()
186
+ } else {
187
+ throw createHttpError(401, 'Unauthorized')
105
188
  }
106
189
  }
107
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.36.2",
3
+ "version": "0.37.1",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -43,7 +43,7 @@
43
43
  "sequelize": "^6.37.7"
44
44
  },
45
45
  "devDependencies": {
46
- "@evanp/activitypub-nock": "^0.7.0",
46
+ "@evanp/activitypub-nock": "^0.9.2",
47
47
  "eslint": "^8.57.1",
48
48
  "eslint-config-standard": "^17.1.0",
49
49
  "eslint-plugin-import": "^2.29.1",