@evanp/activitypub-bot 0.37.0 → 0.38.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/lib/app.js CHANGED
@@ -45,10 +45,12 @@ import { FanoutWorker } from './fanoutworker.js'
45
45
  import { IntakeWorker } from './intakeworker.js'
46
46
  import { RemoteObjectCache } from './remoteobjectcache.js'
47
47
  import { HTTPMessageSignature } from './httpmessagesignature.js'
48
+ import { randomUUID } from 'node:crypto'
48
49
 
49
50
  const currentDir = dirname(fileURLToPath(import.meta.url))
50
51
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
51
52
  const DEFAULT_PROFILE_FILENAME = resolve(currentDir, '..', 'web', 'profile.html')
53
+ const UUID_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
52
54
 
53
55
  function createWorkers (logger, count, WorkerClass, ...args) {
54
56
  const workers = Array.from({ length: count }, () => new WorkerClass(...args))
@@ -182,9 +184,20 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
182
184
  profileFileName
183
185
  }
184
186
 
187
+ app.use(async (req, res, next) => {
188
+ let id = req.get('x-request-id')
189
+ if (!id || !id.match(UUID_REGEXP)) {
190
+ id = randomUUID()
191
+ }
192
+ req.id = id
193
+ res.set('X-Request-ID', id)
194
+ next()
195
+ })
196
+
185
197
  app.use(HTTPLogger({
186
198
  logger,
187
- level: logLevel
199
+ level: logLevel,
200
+ genReqId: (req) => req.id
188
201
  }))
189
202
 
190
203
  app.use(express.json({
@@ -24,7 +24,7 @@ export class HTTPMessageSignature {
24
24
  const inputs = this.#parseSignatureInput(signatureInput)
25
25
  const input = this.#bestInput(inputs)
26
26
  return (input)
27
- ? input.keyId
27
+ ? input.keyid
28
28
  : null
29
29
  }
30
30
 
@@ -39,12 +39,14 @@ export class HTTPMessageSignature {
39
39
  }
40
40
 
41
41
  async sign ({ privateKey, keyId, url, method, headers }) {
42
+ this.#logger.debug({ privateKey, keyId, url, method, headers }, 'signing')
43
+
42
44
  const parsed = new URL(url)
43
45
 
44
46
  const signatureInput = []
45
47
 
46
- signatureInput.push(['@method', method])
47
- signatureInput.push(['@authority', parsed.hostname])
48
+ signatureInput.push(['@method', method.toUpperCase()])
49
+ signatureInput.push(['@authority', parsed.host])
48
50
  signatureInput.push(['@path', parsed.pathname])
49
51
  if (parsed.search) {
50
52
  signatureInput.push(['@query', parsed.search])
@@ -57,34 +59,54 @@ export class HTTPMessageSignature {
57
59
 
58
60
  const created = Math.floor(Date.now() / 1000)
59
61
  const componentList = signatureInput.map(([name]) => `"${name}"`).join(' ')
60
- const signatureParams = `(${componentList});keyId="${keyId}";alg="rsa-v1_5-sha256";created=${created}`
62
+ const signatureParams = `(${componentList});keyid="${keyId}";alg="rsa-v1_5-sha256";created=${created}`
61
63
 
62
64
  signatureInput.push(['@signature-params', signatureParams])
63
65
 
66
+ this.#logger.debug({ signatureInput }, 'built data structure')
67
+
64
68
  const data = signatureInput.map(pair => `"${pair[0]}": ${pair[1]}`).join('\n')
65
69
 
70
+ this.#logger.debug({ data }, 'data')
71
+
66
72
  const signer = crypto.createSign('sha256')
67
73
  signer.update(data)
68
74
  const signature = signer.sign(privateKey).toString('base64')
69
75
  signer.end()
70
76
 
71
- return {
77
+ const result = {
72
78
  'signature-input': `sig1=${signatureParams}`,
73
79
  signature: `sig1=:${signature}:`
74
80
  }
81
+
82
+ this.#logger.debug({ result }, 'returning headers')
83
+
84
+ return result
75
85
  }
76
86
 
77
- async validate (publicKeyPem, signatureInput, signature, method, path, query, headers) {
87
+ async validate (publicKeyPem, signatureInput, signature, method, url, headers) {
88
+ this.#logger.debug(
89
+ { publicKeyPem, signatureInput, signature, method, url, headers }, 'validating signature'
90
+ )
78
91
  const inputs = this.#parseSignatureInput(signatureInput)
92
+ this.#logger.debug(
93
+ { inputs }, 'validating signature'
94
+ )
79
95
  const input = this.#bestInput(inputs)
80
96
  if (!input) {
81
97
  throw new Error('No input with supported algorithms')
82
98
  }
99
+ this.#logger.debug(
100
+ { input }, 'best input'
101
+ )
83
102
  const bytes = this.#sigBytes(signature, input.name)
84
103
  if (!bytes) {
85
104
  throw new Error('No input with supported algorithms')
86
105
  }
87
- const data = this.#inputData(input, method, path, query, headers)
106
+ const data = this.#inputData(input, method, url, headers)
107
+ this.#logger.debug(
108
+ { data }, 'input data'
109
+ )
88
110
  const verifier = this.#getVerifier(input.alg)
89
111
  verifier.update(data)
90
112
  const options = this.#getVerifierOptions(input.alg)
@@ -101,7 +123,11 @@ export class HTTPMessageSignature {
101
123
  const params = match[2].slice(1, -1)
102
124
  .split(' ')
103
125
  .filter(s => s.length > 0)
104
- .map(quoted => quoted.slice(1, -1))
126
+ .map(token => {
127
+ const m = token.match(/^"([^"]+)"(.*)$/)
128
+ if (!m) return token
129
+ return m[2] ? `${m[1]}${m[2]}` : m[1]
130
+ })
105
131
  const sigvals = {}
106
132
  for (const paramMatch of match[3].matchAll(PARAM_RE)) {
107
133
  const k = paramMatch[1]
@@ -123,8 +149,9 @@ export class HTTPMessageSignature {
123
149
  return null
124
150
  }
125
151
 
126
- #inputData (input, method, path, query, headers) {
152
+ #inputData (input, method, url, headers) {
127
153
  const signatureParams = []
154
+ const parsed = URL.parse(url)
128
155
  for (const param of input.params) {
129
156
  let value
130
157
  switch (param) {
@@ -135,18 +162,43 @@ export class HTTPMessageSignature {
135
162
  value = headers.host
136
163
  break
137
164
  case '@path':
138
- value = path
165
+ value = parsed.pathname
139
166
  break
140
167
  case '@query':
141
- value = query
168
+ value = parsed.search
169
+ break
170
+ case '@target-uri':
171
+ value = url
172
+ break
173
+ case '@scheme':
174
+ value = parsed.protocol.slice(0, -1)
175
+ break
176
+ case '@request-target':
177
+ value = (parsed.search)
178
+ ? `${parsed.pathname}${parsed.search}`
179
+ : parsed.pathname
142
180
  break
143
181
  default:
182
+ if (param.startsWith('@query-param')) {
183
+ const nameMatch = param.match(/;name="([^"]+)"/)
184
+ if (!nameMatch) throw new Error('Missing name for @query-param')
185
+ const paramName = nameMatch[1]
186
+ signatureParams.push(['@query-param', parsed.searchParams.get(paramName), `name="${paramName}"`])
187
+ continue
188
+ }
189
+ if (param.length > 0 && param[0] === '@') {
190
+ throw new Error(`Unrecognized derived component ${param}`)
191
+ }
144
192
  value = headers[param]
145
193
  }
146
194
  signatureParams.push([param, value])
147
195
  }
148
196
  signatureParams.push(['@signature-params', input.attrStr])
149
- return signatureParams.map(pair => `"${pair[0]}": ${pair[1]}`).join('\n')
197
+ return signatureParams.map(
198
+ arr => arr.length === 3
199
+ ? `"${arr[0]}";${arr[2]}: ${arr[1]}`
200
+ : `"${arr[0]}": ${arr[1]}`
201
+ ).join('\n')
150
202
  }
151
203
 
152
204
  #getVerifier (alg) {
@@ -130,6 +130,15 @@ export class HTTPSignatureAuthenticator {
130
130
  }
131
131
 
132
132
  async #authenticateMessageSignature (signature, signatureInput, originalUrl, req, res, next) {
133
+ this.#logger.debug(
134
+ { signature, signatureInput, originalUrl },
135
+ 'authenticating message signature'
136
+ )
137
+ const date = req.get('Date')
138
+ if (date && Math.abs(Date.parse(date) - Date.now()) >
139
+ HTTPSignatureAuthenticator.#maxDateDiff) {
140
+ throw createHttpError(400, 'Time skew too large')
141
+ }
133
142
  if (req.rawBodyText && req.rawBodyText.length > 0) {
134
143
  const digest = req.get('Content-Digest')
135
144
  if (!digest) {
@@ -150,7 +159,7 @@ export class HTTPSignatureAuthenticator {
150
159
  }
151
160
  const { method, headers } = req
152
161
  const { origin } = req.app.locals
153
- const { pathname: path, search: query } = URL.parse(`${origin}${originalUrl}`)
162
+ const url = `${origin}${originalUrl}`
154
163
 
155
164
  const keyId = this.#messageSigner.keyId(signatureInput)
156
165
  if (!keyId) {
@@ -163,7 +172,7 @@ export class HTTPSignatureAuthenticator {
163
172
  }
164
173
  let owner = ok.owner
165
174
  let publicKeyPem = ok.publicKeyPem
166
- let result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, path, query, headers)
175
+ let result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, url, headers)
167
176
  this.#logger.debug(`First validation result: ${result}`)
168
177
  if (!result) {
169
178
  // May be key rotation. Try again with uncached key
@@ -174,7 +183,7 @@ export class HTTPSignatureAuthenticator {
174
183
  this.#logger.debug('different keys')
175
184
  owner = ok2.owner
176
185
  publicKeyPem = ok2.publicKeyPem
177
- result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, path, query, headers)
186
+ result = await this.#messageSigner.validate(publicKeyPem, signatureInput, signature, method, url, headers)
178
187
  this.#logger.debug(`Validation result: ${result}`)
179
188
  }
180
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
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.8.1",
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",