@webdecoy/fcaptcha 1.16.0 → 1.17.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/limits.js +0 -0
- package/package.json +3 -2
- package/server.js +27 -24
- package/webbotauth.js +194 -35
package/limits.js
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"start": "node server.js",
|
|
13
13
|
"dev": "node --watch server.js",
|
|
14
14
|
"test:webbotauth": "node webbotauth.test.js",
|
|
15
|
-
"test:clientip": "node clientip.test.js"
|
|
15
|
+
"test:clientip": "node clientip.test.js",
|
|
16
|
+
"test:limits": "node limits.test.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"cors": "^2.8.5",
|
package/server.js
CHANGED
|
@@ -11,6 +11,7 @@ const path = require('path');
|
|
|
11
11
|
const detection = require('./detection');
|
|
12
12
|
const webbotauth = require('./webbotauth');
|
|
13
13
|
const { ProxyTrust } = require('./clientip');
|
|
14
|
+
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
14
15
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(cors());
|
|
@@ -24,6 +25,12 @@ const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
|
|
|
24
25
|
// the TLS-fingerprint headers. See clientip.js.
|
|
25
26
|
const PROXY_TRUST = ProxyTrust.fromEnv();
|
|
26
27
|
|
|
28
|
+
// siteKey is client-supplied and validated against no registry, yet it is the
|
|
29
|
+
// first component of every rate-limit, fingerprint and challenge partition key.
|
|
30
|
+
// SITE_KEYS bounds how many distinct values one IP may allocate state for. See
|
|
31
|
+
// limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
|
|
32
|
+
const SITE_KEYS = SiteKeyGuard.fromEnv();
|
|
33
|
+
|
|
27
34
|
// Express's own `trust proxy` would re-derive req.ip from the same headers on
|
|
28
35
|
// its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
|
|
29
36
|
app.set('trust proxy', false);
|
|
@@ -85,8 +92,8 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
|
|
|
85
92
|
|
|
86
93
|
// PoW Challenge Store
|
|
87
94
|
const powChallengeStore = {
|
|
88
|
-
challenges: new
|
|
89
|
-
usedSolutions: new
|
|
95
|
+
challenges: new BoundedMap(),
|
|
96
|
+
usedSolutions: new BoundedSet(),
|
|
90
97
|
|
|
91
98
|
// Generate a new challenge
|
|
92
99
|
generate(siteKey, ip, difficulty = 4) {
|
|
@@ -177,20 +184,15 @@ const powChallengeStore = {
|
|
|
177
184
|
|
|
178
185
|
_cleanup() {
|
|
179
186
|
const now = Date.now();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
// Cleanup old solutions (keep last hour)
|
|
186
|
-
if (this.usedSolutions.size > 10000) {
|
|
187
|
-
this.usedSolutions.clear();
|
|
188
|
-
}
|
|
187
|
+
this.challenges.prune((challenge) => now <= challenge.expiresAt);
|
|
188
|
+
// usedSolutions is a BoundedSet: it evicts its own oldest entries as it
|
|
189
|
+
// fills. The previous code cleared the whole replay guard once it passed a
|
|
190
|
+
// threshold, which an attacker could force in order to replay a solution.
|
|
189
191
|
}
|
|
190
192
|
};
|
|
191
193
|
|
|
192
194
|
const rateLimiter = {
|
|
193
|
-
requests: new
|
|
195
|
+
requests: new BoundedMap(),
|
|
194
196
|
|
|
195
197
|
check(key, windowSeconds = 60, maxRequests = 10) {
|
|
196
198
|
const now = Date.now();
|
|
@@ -211,8 +213,8 @@ const rateLimiter = {
|
|
|
211
213
|
};
|
|
212
214
|
|
|
213
215
|
const fingerprintStore = {
|
|
214
|
-
fingerprints: new
|
|
215
|
-
ipFingerprints: new
|
|
216
|
+
fingerprints: new BoundedMap(),
|
|
217
|
+
ipFingerprints: new BoundedMap(),
|
|
216
218
|
|
|
217
219
|
record(fp, ip, siteKey) {
|
|
218
220
|
const key = `${siteKey}:${fp}`;
|
|
@@ -242,7 +244,7 @@ const fingerprintStore = {
|
|
|
242
244
|
|
|
243
245
|
// Token Store - prevents token replay attacks
|
|
244
246
|
const tokenStore = {
|
|
245
|
-
usedTokens: new
|
|
247
|
+
usedTokens: new BoundedSet(),
|
|
246
248
|
|
|
247
249
|
// Mark a token as used (returns false if already used)
|
|
248
250
|
markUsed(tokenSig) {
|
|
@@ -261,11 +263,9 @@ const tokenStore = {
|
|
|
261
263
|
},
|
|
262
264
|
|
|
263
265
|
_cleanup() {
|
|
264
|
-
// In production with Redis, use TTL instead
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
this.usedTokens.clear();
|
|
268
|
-
}
|
|
266
|
+
// In production with Redis, use TTL instead. In memory usedTokens is a
|
|
267
|
+
// BoundedSet, so it evicts its oldest entries rather than clearing wholesale
|
|
268
|
+
// — clearing would let an attacker who forced the threshold replay a token.
|
|
269
269
|
}
|
|
270
270
|
};
|
|
271
271
|
|
|
@@ -1260,8 +1260,10 @@ function collectHeaders(req) {
|
|
|
1260
1260
|
}
|
|
1261
1261
|
|
|
1262
1262
|
app.post('/api/verify', async (req, res) => {
|
|
1263
|
-
const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1263
|
+
const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
|
|
1264
1264
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1265
|
+
// Bound the state an unvalidated siteKey can allocate (limits.js).
|
|
1266
|
+
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
1265
1267
|
const userAgent = req.headers['user-agent'] || '';
|
|
1266
1268
|
// Only honoured from a trusted proxy: a client that can state its own TLS
|
|
1267
1269
|
// fingerprint would just claim a stock Chrome one.
|
|
@@ -1279,8 +1281,9 @@ app.post('/api/verify', async (req, res) => {
|
|
|
1279
1281
|
});
|
|
1280
1282
|
|
|
1281
1283
|
app.post('/api/score', async (req, res) => {
|
|
1282
|
-
const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1284
|
+
const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
|
|
1283
1285
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1286
|
+
const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
|
|
1284
1287
|
const userAgent = req.headers['user-agent'] || '';
|
|
1285
1288
|
const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
|
|
1286
1289
|
|
|
@@ -1314,9 +1317,8 @@ app.post('/api/token/verify', (req, res) => {
|
|
|
1314
1317
|
|
|
1315
1318
|
// PoW Challenge endpoint - client fetches this on page load
|
|
1316
1319
|
app.get('/api/pow/challenge', (req, res) => {
|
|
1317
|
-
const siteKey = req.query.siteKey || 'default';
|
|
1318
|
-
|
|
1319
1320
|
const ip = PROXY_TRUST.clientIP(req);
|
|
1321
|
+
const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
|
|
1320
1322
|
|
|
1321
1323
|
// Difficulty scaling based on IP reputation
|
|
1322
1324
|
let difficulty = 4; // Default: ~100-500ms on average hardware
|
|
@@ -1365,4 +1367,5 @@ app.get('/api/challenge', (req, res) => {
|
|
|
1365
1367
|
app.listen(PORT, () => {
|
|
1366
1368
|
console.log(`FCaptcha server running on port ${PORT}`);
|
|
1367
1369
|
console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
|
|
1370
|
+
console.log(`Site keys: ${SITE_KEYS.describe()}`);
|
|
1368
1371
|
});
|
package/webbotauth.js
CHANGED
|
@@ -23,8 +23,33 @@
|
|
|
23
23
|
//
|
|
24
24
|
// The upstream package (cloudflare/web-bot-auth) is the reference implementation
|
|
25
25
|
// the Go module (WebDecoy/web-bot-auth) is cross-validated against, so the two
|
|
26
|
-
// servers agree on verdicts. Python
|
|
27
|
-
//
|
|
26
|
+
// servers agree on verdicts. Python stays presence-only for now, though a
|
|
27
|
+
// maintained RFC 9421 implementation (pyauth/http-message-signatures) now exists
|
|
28
|
+
// and would close that gap.
|
|
29
|
+
//
|
|
30
|
+
// Wire format — draft-meunier-webbotauth-httpsig-protocol-00 (2026-06-26):
|
|
31
|
+
// Signature-Agent is a Structured Fields Dictionary whose member value is the
|
|
32
|
+
// discovery URL and whose optional `type` parameter selects how to resolve keys:
|
|
33
|
+
//
|
|
34
|
+
// sig1="https://a.test" → directory (default)
|
|
35
|
+
// sig1="https://a.test/jwks.json";type=jwks_uri → the URL *is* a JWK Set
|
|
36
|
+
// sig1="https://a.test/card";type=cimd → Client ID Metadata Document
|
|
37
|
+
//
|
|
38
|
+
// The pre-00 bare-String form ("https://a.test") is still emitted by signers
|
|
39
|
+
// that have not migrated and stays supported. A parser that only understood the
|
|
40
|
+
// bare form silently lost the *verified* verdict for any signer using the two
|
|
41
|
+
// new discovery types, degrading them to presence-only.
|
|
42
|
+
//
|
|
43
|
+
// DOCUMENTED DIVERGENCE — `cimd`. This implementation resolves a Client ID
|
|
44
|
+
// Metadata Document (embedded `jwks`, or a nested `jwks_uri` re-fetched through
|
|
45
|
+
// the same guard). The Go module (WebDecoy/web-bot-auth v0.2.0) parses the type
|
|
46
|
+
// but returns "not resolvable", so Go fails open to presence-only where Node
|
|
47
|
+
// reaches verified. Go is otherwise fully protocol-00 compliant — it honors
|
|
48
|
+
// `directory` and `jwks_uri` and enforces the tag — so this is the single
|
|
49
|
+
// verdict difference. It is currently theoretical: no observed signer uses
|
|
50
|
+
// `cimd` (chatgpt.com and agent.bot.goog both serve `directory`). Parity
|
|
51
|
+
// requires a change in the Go module, not here. Tracked rather than silent, per
|
|
52
|
+
// the CLAUDE.md sync rule.
|
|
28
53
|
|
|
29
54
|
const https = require('https');
|
|
30
55
|
const dns = require('dns');
|
|
@@ -48,6 +73,23 @@ const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
48
73
|
// signature. Everything else (fetch failure, expiry, missing key) fails open.
|
|
49
74
|
const CRYPTO_FAILURE_MESSAGE = 'invalid signature';
|
|
50
75
|
|
|
76
|
+
// Discovery types from protocol-00. `directory` is the default when the header
|
|
77
|
+
// carries no `type` parameter.
|
|
78
|
+
const DISCOVERY_DIRECTORY = 'directory';
|
|
79
|
+
const DISCOVERY_JWKS_URI = 'jwks_uri';
|
|
80
|
+
const DISCOVERY_CIMD = 'cimd';
|
|
81
|
+
|
|
82
|
+
// protocol-00: `tag` MUST be "web-bot-auth". A signature tagged for some other
|
|
83
|
+
// protocol is not a web-bot-auth signature, so it earns no verified credit.
|
|
84
|
+
const REQUIRED_TAG = 'web-bot-auth';
|
|
85
|
+
|
|
86
|
+
// protocol-00 RECOMMENDS an expiry no more than 24h out. Because that is a
|
|
87
|
+
// RECOMMENDED and not a MUST, an over-long window is not treated as forgery or
|
|
88
|
+
// as a hard rejection — it just does not earn the *verified* verdict, and the
|
|
89
|
+
// request falls back to presence-only. That bounds the replay window a stolen
|
|
90
|
+
// signature buys without false-positiving a signer who picked a longer TTL.
|
|
91
|
+
const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
|
|
92
|
+
|
|
51
93
|
// origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
|
|
52
94
|
const directoryCache = new Map();
|
|
53
95
|
|
|
@@ -96,11 +138,14 @@ function guardedLookup(hostname, options, callback) {
|
|
|
96
138
|
|
|
97
139
|
// --- directory fetching -------------------------------------------------------
|
|
98
140
|
|
|
99
|
-
|
|
141
|
+
// fetchJSON retrieves and parses one https document under the SSRF guard, the
|
|
142
|
+
// size cap and the timeout. Shared by directory, jwks_uri and cimd fetches so a
|
|
143
|
+
// new discovery type cannot accidentally bypass the guard.
|
|
144
|
+
function fetchJSON(documentUrl) {
|
|
100
145
|
return new Promise((resolve, reject) => {
|
|
101
146
|
let url;
|
|
102
147
|
try {
|
|
103
|
-
url = new URL(
|
|
148
|
+
url = new URL(documentUrl);
|
|
104
149
|
} catch {
|
|
105
150
|
reject(new FetchFailure('invalid directory URL'));
|
|
106
151
|
return;
|
|
@@ -111,7 +156,7 @@ function fetchDirectory(directoryUrl) {
|
|
|
111
156
|
}
|
|
112
157
|
|
|
113
158
|
const req = https.get(
|
|
114
|
-
|
|
159
|
+
documentUrl,
|
|
115
160
|
{
|
|
116
161
|
lookup: guardedLookup,
|
|
117
162
|
timeout: VERIFY_TIMEOUT_MS,
|
|
@@ -138,8 +183,7 @@ function fetchDirectory(directoryUrl) {
|
|
|
138
183
|
});
|
|
139
184
|
res.on('end', () => {
|
|
140
185
|
try {
|
|
141
|
-
|
|
142
|
-
resolve(Array.isArray(doc.keys) ? doc.keys : []);
|
|
186
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
143
187
|
} catch {
|
|
144
188
|
reject(new FetchFailure('directory is not valid JSON'));
|
|
145
189
|
}
|
|
@@ -156,14 +200,46 @@ function fetchDirectory(directoryUrl) {
|
|
|
156
200
|
});
|
|
157
201
|
}
|
|
158
202
|
|
|
159
|
-
|
|
203
|
+
// fetchKeySet pulls a JWK Set — the shape served by both the well-known
|
|
204
|
+
// directory and a jwks_uri.
|
|
205
|
+
async function fetchKeySet(url) {
|
|
206
|
+
const doc = await fetchJSON(url);
|
|
207
|
+
return doc && Array.isArray(doc.keys) ? doc.keys : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// fetchViaCIMD resolves a Client ID Metadata Document, which either embeds the
|
|
211
|
+
// JWK Set inline or points at one. The nested jwks_uri is re-validated and
|
|
212
|
+
// re-fetched through the same guard rather than trusted.
|
|
213
|
+
async function fetchViaCIMD(cimdUrl) {
|
|
214
|
+
const doc = await fetchJSON(cimdUrl);
|
|
215
|
+
if (doc && doc.jwks && Array.isArray(doc.jwks.keys)) {
|
|
216
|
+
return doc.jwks.keys;
|
|
217
|
+
}
|
|
218
|
+
if (doc && typeof doc.jwks_uri === 'string') {
|
|
219
|
+
let nested;
|
|
220
|
+
try {
|
|
221
|
+
nested = new URL(doc.jwks_uri);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new FetchFailure('cimd jwks_uri is not a URL');
|
|
224
|
+
}
|
|
225
|
+
if (nested.protocol !== 'https:') {
|
|
226
|
+
throw new FetchFailure('cimd jwks_uri is not https');
|
|
227
|
+
}
|
|
228
|
+
return fetchKeySet(nested.toString());
|
|
229
|
+
}
|
|
230
|
+
throw new FetchFailure('cimd document has no jwks or jwks_uri');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// getKeys caches by resolved URL, so the three discovery types share one cache
|
|
234
|
+
// without colliding.
|
|
235
|
+
async function getKeys(url, type) {
|
|
160
236
|
const now = Date.now();
|
|
161
|
-
const cached = directoryCache.get(
|
|
237
|
+
const cached = directoryCache.get(url);
|
|
162
238
|
if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
|
|
163
239
|
return cached.keys;
|
|
164
240
|
}
|
|
165
|
-
const keys = await
|
|
166
|
-
directoryCache.set(
|
|
241
|
+
const keys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
|
|
242
|
+
directoryCache.set(url, { keys, fetchedAt: now });
|
|
167
243
|
return keys;
|
|
168
244
|
}
|
|
169
245
|
|
|
@@ -173,11 +249,11 @@ async function getDirectory(directoryUrl) {
|
|
|
173
249
|
// lacks the key throws a FetchFailure — treated as fail-open, matching the Go
|
|
174
250
|
// engine, which counts only a crypto failure as forgery.
|
|
175
251
|
async function resolveKey(signatureAgentHeader, keyid) {
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
178
|
-
throw new FetchFailure('no resolvable Signature-Agent
|
|
252
|
+
const source = keySourceFromHeader(signatureAgentHeader);
|
|
253
|
+
if (!source) {
|
|
254
|
+
throw new FetchFailure('no resolvable Signature-Agent key source');
|
|
179
255
|
}
|
|
180
|
-
const keys = await
|
|
256
|
+
const keys = await getKeys(source.url, source.type);
|
|
181
257
|
for (const jwk of keys) {
|
|
182
258
|
let thumbprint;
|
|
183
259
|
try {
|
|
@@ -194,31 +270,63 @@ async function resolveKey(signatureAgentHeader, keyid) {
|
|
|
194
270
|
|
|
195
271
|
// --- header + request helpers -------------------------------------------------
|
|
196
272
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
273
|
+
// parseSignatureAgent reads the first member of the Signature-Agent Structured
|
|
274
|
+
// Fields Dictionary, returning its URL and discovery type. Accepts, in order of
|
|
275
|
+
// how signers actually emit them:
|
|
276
|
+
//
|
|
277
|
+
// sig1="https://a.test/jwks.json";type=jwks_uri protocol-00, typed
|
|
278
|
+
// sig1="https://a.test" protocol-00, default type
|
|
279
|
+
// "https://a.test" pre-00 bare String
|
|
280
|
+
//
|
|
281
|
+
// The previous implementation anchored on a closing quote at end-of-string, so
|
|
282
|
+
// any trailing parameter fell through to the raw header text and failed to
|
|
283
|
+
// parse as a URL. That silently cost the *verified* verdict.
|
|
284
|
+
function parseSignatureAgent(raw) {
|
|
285
|
+
if (!raw) return null;
|
|
202
286
|
const s = String(raw).trim();
|
|
203
|
-
|
|
204
|
-
|
|
287
|
+
if (!s) return null;
|
|
288
|
+
// Optional `key=` prefix, a quoted SF String, then optional ;params up to the
|
|
289
|
+
// next dictionary member.
|
|
290
|
+
const m = s.match(/^(?:[A-Za-z0-9_*-]+\s*=\s*)?"([^"]*)"((?:\s*;[^,]*)?)/);
|
|
291
|
+
if (!m) return null;
|
|
292
|
+
const url = m[1].trim();
|
|
293
|
+
if (!url) return null;
|
|
294
|
+
const typeMatch = (m[2] || '').match(/;\s*type\s*=\s*"?([A-Za-z0-9_-]+)"?/i);
|
|
295
|
+
const type = typeMatch ? typeMatch[1].toLowerCase() : DISCOVERY_DIRECTORY;
|
|
296
|
+
return { url, type };
|
|
205
297
|
}
|
|
206
298
|
|
|
207
299
|
function displayAgent(raw) {
|
|
208
|
-
|
|
300
|
+
const parsed = parseSignatureAgent(raw);
|
|
301
|
+
return (parsed && parsed.url) || 'unknown';
|
|
209
302
|
}
|
|
210
303
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
304
|
+
// keySourceFromHeader resolves the header to the concrete https URL keys are
|
|
305
|
+
// fetched from, plus the discovery type that says how to interpret it. Returns
|
|
306
|
+
// null for anything unusable — a non-https URL, or a discovery type this build
|
|
307
|
+
// does not implement (guessing would be worse than failing open).
|
|
308
|
+
function keySourceFromHeader(raw) {
|
|
309
|
+
const parsed = parseSignatureAgent(raw);
|
|
310
|
+
if (!parsed) return null;
|
|
214
311
|
let u;
|
|
215
312
|
try {
|
|
216
|
-
u = new URL(
|
|
313
|
+
u = new URL(parsed.url);
|
|
217
314
|
} catch {
|
|
218
315
|
return null;
|
|
219
316
|
}
|
|
220
317
|
if (u.protocol !== 'https:') return null;
|
|
221
|
-
|
|
318
|
+
|
|
319
|
+
switch (parsed.type) {
|
|
320
|
+
case DISCOVERY_DIRECTORY:
|
|
321
|
+
// The member names the signer's origin; keys live at the well-known path.
|
|
322
|
+
return { url: u.origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, type: DISCOVERY_DIRECTORY };
|
|
323
|
+
case DISCOVERY_JWKS_URI:
|
|
324
|
+
case DISCOVERY_CIMD:
|
|
325
|
+
// The member names the document itself — path and query are significant.
|
|
326
|
+
return { url: u.toString(), type: parsed.type };
|
|
327
|
+
default:
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
222
330
|
}
|
|
223
331
|
|
|
224
332
|
function forwardedScheme(req) {
|
|
@@ -298,6 +406,7 @@ async function checkWebBotAuth(req) {
|
|
|
298
406
|
if (!sigAgentRaw) {
|
|
299
407
|
throw new FetchFailure('no Signature-Agent to resolve key');
|
|
300
408
|
}
|
|
409
|
+
assertWebBotAuthParams(params);
|
|
301
410
|
const jwk = await resolveKey(sigAgentRaw, params.keyid);
|
|
302
411
|
resolvedKeyId = params.keyid;
|
|
303
412
|
resolvedAlg = jwk.alg || (jwk.crv === 'Ed25519' ? 'ed25519' : '');
|
|
@@ -323,6 +432,51 @@ async function checkWebBotAuth(req) {
|
|
|
323
432
|
}
|
|
324
433
|
}
|
|
325
434
|
|
|
435
|
+
// toEpochSeconds normalizes a signature timestamp to epoch seconds. RFC 9421
|
|
436
|
+
// puts integer seconds on the wire, but web-bot-auth hands the verifier callback
|
|
437
|
+
// `Date` objects — so a naive Number() yields milliseconds and any duration
|
|
438
|
+
// comparison is off by 1000×. Accepts Date, seconds, or milliseconds.
|
|
439
|
+
function toEpochSeconds(value) {
|
|
440
|
+
if (value instanceof Date) return Math.floor(value.getTime() / 1000);
|
|
441
|
+
const n = Number(value);
|
|
442
|
+
if (!Number.isFinite(n)) return NaN;
|
|
443
|
+
// Epoch seconds are ~1.8e9 today; epoch milliseconds ~1.8e12.
|
|
444
|
+
return n > 1e11 ? Math.floor(n / 1000) : n;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// assertWebBotAuthParams enforces the protocol-00 signature-parameter rules that
|
|
448
|
+
// the underlying library does not.
|
|
449
|
+
//
|
|
450
|
+
// web-bot-auth's own verify() already rejects a wrong `tag`, a `created` in the
|
|
451
|
+
// future, an expired `expires`, and a missing `keyid`. The tag check below is
|
|
452
|
+
// therefore belt-and-braces — kept because it is free and because it documents a
|
|
453
|
+
// MUST that our verdict depends on. What is genuinely additive is the lifetime
|
|
454
|
+
// ceiling: nothing upstream bounds how far out `expires` may sit, and a
|
|
455
|
+
// long-lived signature is a long-lived replay token.
|
|
456
|
+
//
|
|
457
|
+
// Throwing FetchFailure means "not a verifiable web-bot-auth signature", which
|
|
458
|
+
// the caller renders as presence-only — never as forgery, since neither a wrong
|
|
459
|
+
// tag nor a generous TTL is cryptographic proof that anyone spoofed anything.
|
|
460
|
+
function assertWebBotAuthParams(params) {
|
|
461
|
+
const p = params || {};
|
|
462
|
+
|
|
463
|
+
if (p.tag !== REQUIRED_TAG) {
|
|
464
|
+
throw new FetchFailure(`signature tag is not ${REQUIRED_TAG}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// `expires` is what bounds replay; a signature without one is unbounded.
|
|
468
|
+
const expires = toEpochSeconds(p.expires);
|
|
469
|
+
if (!Number.isFinite(expires)) {
|
|
470
|
+
throw new FetchFailure('signature has no expires parameter');
|
|
471
|
+
}
|
|
472
|
+
const created = Number.isFinite(toEpochSeconds(p.created))
|
|
473
|
+
? toEpochSeconds(p.created)
|
|
474
|
+
: Math.floor(Date.now() / 1000);
|
|
475
|
+
if (expires - created > MAX_SIGNATURE_LIFETIME_S) {
|
|
476
|
+
throw new FetchFailure('signature lifetime exceeds 24h');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
326
480
|
// withTimeout bounds the whole verification, a backstop over the per-fetch
|
|
327
481
|
// timeout so a stalled directory can never hold the scoring path open.
|
|
328
482
|
function withTimeout(promise, ms) {
|
|
@@ -346,19 +500,24 @@ module.exports = {
|
|
|
346
500
|
// Exported for unit tests only.
|
|
347
501
|
_internal: {
|
|
348
502
|
isPublicUnicast,
|
|
349
|
-
|
|
503
|
+
parseSignatureAgent,
|
|
504
|
+
keySourceFromHeader,
|
|
505
|
+
assertWebBotAuthParams,
|
|
350
506
|
displayAgent,
|
|
351
507
|
verifiedDetection,
|
|
352
508
|
forgedDetection,
|
|
353
509
|
presenceDetection,
|
|
354
|
-
|
|
510
|
+
REQUIRED_TAG,
|
|
511
|
+
MAX_SIGNATURE_LIFETIME_S,
|
|
512
|
+
// Seeds the key cache so an end-to-end verify can exercise resolveKey
|
|
355
513
|
// without a network fetch (the SSRF guard refuses the loopback address a
|
|
356
514
|
// local test server would listen on). Test-only.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
515
|
+
//
|
|
516
|
+
// Seeds by *origin* for the default directory discovery. Pass a full URL as
|
|
517
|
+
// `exactUrl` to seed a jwks_uri/cimd source instead.
|
|
518
|
+
seedDirectoryCache(origin, keys, exactUrl) {
|
|
519
|
+
const url = exactUrl || origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY;
|
|
520
|
+
directoryCache.set(url, { keys, fetchedAt: Date.now() });
|
|
362
521
|
},
|
|
363
522
|
},
|
|
364
523
|
};
|