Skip to content

Commit 367a633

Browse files
authored
Merge pull request #575 from Podcastindex-org/aggrivator-bot-signing
add well-known signing directory for web bot auth support in aggrivator
2 parents 4afafe2 + 2d2e713 commit 367a633

4 files changed

Lines changed: 158 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"production": "NODE_ENV='production' && webpack --env.file=production --mode production && node server",
8686
"start": "node server",
8787
"test": "react-scripts test --env=jsdom",
88+
"test:server": "node server/test/well-known-signing-directory.test.js",
8889
"eject": "react-scripts eject",
8990
"flow": "flow",
9091
"format": "prettier --write 'src/**/*.js'",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"keys": [
3+
{
4+
"kty": "OKP",
5+
"crv": "Ed25519",
6+
"x": "REPLACE_ME",
7+
"kid": "REPLACE_ME",
8+
"use": "sig"
9+
}
10+
]
11+
}

server/index.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,50 @@ app.get('/.well-known/lnurlp/podcastindex', (req, res) => {
109109
res.redirect('https://getalby.com/.well-known/lnurlp/podcastindex')
110110
})
111111

112+
// ------------------------------------------------
113+
// ------ Web Bot Auth key directory (JWKS) -------
114+
// ------------------------------------------------
115+
//
116+
// Publishes the PUBLIC Ed25519 key(s) the Aggrivator crawler uses to sign its
117+
// requests, so Cloudflare and other verifiers can confirm a signed request
118+
// really comes from us (Web Bot Auth / RFC 9421 HTTP Message Signatures).
119+
//
120+
// The body is served from server/data/http-message-signatures-directory.json so
121+
// that adding/rotating a key is a content edit, not a code change. The exact
122+
// media type below is mandatory — verifiers reject application/json.
123+
//
124+
// TODO(ops): replace the REPLACE_ME placeholder values in that JSON file with
125+
// the real JWKS produced on the signing server. Never put private key material
126+
// (a "d" member) in that file. The `kid` must match the crawler's advertised
127+
// keyid.
128+
//
129+
// CDN/WAF: this endpoint must be reachable by automated clients with no bot
130+
// challenge, redirect, or auth. The app applies no such gating to this path
131+
// (the POW middleware only guards /api/*), but if Cloudflare (or any WAF) is in
132+
// front of the site, ensure this exact path is allowed through unchallenged.
133+
app.get('/.well-known/http-message-signatures-directory', (req, res) => {
134+
fs.readFile(
135+
'./server/data/http-message-signatures-directory.json',
136+
'utf8',
137+
(err, data) => {
138+
if (err) {
139+
res.status(500).json({ error: 'key directory unavailable' })
140+
return
141+
}
142+
// Use setHeader (raw Node) rather than res.set/res.type so Express does
143+
// not append "; charset=utf-8" — verifiers expect this media type exactly.
144+
res.setHeader(
145+
'Content-Type',
146+
'application/http-message-signatures-directory+json'
147+
)
148+
res.setHeader('Cache-Control', 'public, max-age=3600')
149+
// Send a Buffer, not a string: res.send() force-appends "; charset=utf-8"
150+
// to the Content-Type for string bodies, which we must avoid here.
151+
res.send(Buffer.from(data))
152+
}
153+
)
154+
})
155+
112156
// ------------------------------------------------
113157
// ------------ Reverse proxy for API -------------
114158
// ------------------------------------------------
@@ -452,6 +496,11 @@ app.get('*', (req, res) => {
452496
const PORT = process.env.PORT || 333
453497

454498
// start express server on port 5001 (default)
455-
app.listen(PORT, () => {
456-
console.log(`server started on port ${PORT}`)
457-
})
499+
// Only listen when run directly, so the app can be imported by tests.
500+
if (require.main === module) {
501+
app.listen(PORT, () => {
502+
console.log(`server started on port ${PORT}`)
503+
})
504+
}
505+
506+
module.exports = app
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Self-contained test for the Web Bot Auth key directory endpoint:
3+
* GET /.well-known/http-message-signatures-directory
4+
*
5+
* There is no server-side test runner in this repo (`yarn test` is react-scripts
6+
* for the UI), so this file is a plain Node script: boot the Express app on an
7+
* ephemeral port, make real requests, assert, exit non-zero on failure.
8+
*
9+
* Run with: node server/test/well-known-signing-directory.test.js
10+
*/
11+
const assert = require('assert')
12+
const fetch = require('node-fetch')
13+
const app = require('../index')
14+
15+
const PATH = '/.well-known/http-message-signatures-directory'
16+
const EXPECTED_CONTENT_TYPE =
17+
'application/http-message-signatures-directory+json'
18+
19+
async function run() {
20+
const server = app.listen(0)
21+
await new Promise((resolve) => server.once('listening', resolve))
22+
const { port } = server.address()
23+
const url = `http://127.0.0.1:${port}${PATH}`
24+
25+
let failures = 0
26+
const check = (name, fn) => {
27+
try {
28+
fn()
29+
console.log(` ok - ${name}`)
30+
} catch (err) {
31+
failures++
32+
console.error(` FAIL - ${name}\n ${err.message}`)
33+
}
34+
}
35+
36+
try {
37+
// --- GET ---
38+
const getRes = await fetch(url)
39+
const body = await getRes.text()
40+
41+
check('GET returns 200', () => assert.strictEqual(getRes.status, 200))
42+
check('GET sets exact Content-Type', () =>
43+
assert.strictEqual(
44+
getRes.headers.get('content-type'),
45+
EXPECTED_CONTENT_TYPE
46+
)
47+
)
48+
check('GET sets a public Cache-Control', () => {
49+
const cc = getRes.headers.get('cache-control') || ''
50+
assert.ok(/public/.test(cc) && /max-age=\d+/.test(cc), `got "${cc}"`)
51+
})
52+
check('GET body is valid JSON with a keys array', () => {
53+
const json = JSON.parse(body)
54+
assert.ok(Array.isArray(json.keys), 'keys is not an array')
55+
assert.ok(json.keys.length >= 1, 'keys is empty')
56+
const k = json.keys[0]
57+
assert.strictEqual(k.kty, 'OKP')
58+
assert.strictEqual(k.crv, 'Ed25519')
59+
assert.ok('x' in k, 'missing x')
60+
assert.ok('kid' in k, 'missing kid')
61+
})
62+
check('GET body contains no private key material', () => {
63+
assert.ok(!/"d"\s*:/.test(body), 'response contains a private key "d"')
64+
})
65+
66+
// --- HEAD ---
67+
const headRes = await fetch(url, { method: 'HEAD' })
68+
const headBody = await headRes.text()
69+
check('HEAD returns 200', () => assert.strictEqual(headRes.status, 200))
70+
check('HEAD sets exact Content-Type', () =>
71+
assert.strictEqual(
72+
headRes.headers.get('content-type'),
73+
EXPECTED_CONTENT_TYPE
74+
)
75+
)
76+
check('HEAD has an empty body', () =>
77+
assert.strictEqual(headBody, '')
78+
)
79+
} finally {
80+
server.close()
81+
}
82+
83+
if (failures > 0) {
84+
console.error(`\n${failures} check(s) failed`)
85+
process.exit(1)
86+
}
87+
console.log('\nAll checks passed')
88+
process.exit(0)
89+
}
90+
91+
run().catch((err) => {
92+
console.error(err)
93+
process.exit(1)
94+
})

0 commit comments

Comments
 (0)