@naulon/enforce 0.1.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/README.md +89 -0
- package/dist/agentDetect.d.ts +94 -0
- package/dist/agentDetect.d.ts.map +1 -0
- package/dist/agentDetect.js +149 -0
- package/dist/agentDetect.js.map +1 -0
- package/dist/botAuth.d.ts +159 -0
- package/dist/botAuth.d.ts.map +1 -0
- package/dist/botAuth.js +659 -0
- package/dist/botAuth.js.map +1 -0
- package/dist/build402.d.ts +46 -0
- package/dist/build402.d.ts.map +1 -0
- package/dist/build402.js +137 -0
- package/dist/build402.js.map +1 -0
- package/dist/decide.d.ts +125 -0
- package/dist/decide.d.ts.map +1 -0
- package/dist/decide.js +221 -0
- package/dist/decide.js.map +1 -0
- package/dist/discoverability.d.ts +68 -0
- package/dist/discoverability.d.ts.map +1 -0
- package/dist/discoverability.js +62 -0
- package/dist/discoverability.js.map +1 -0
- package/dist/enforce/fetch-handler.d.ts +12 -0
- package/dist/enforce/fetch-handler.d.ts.map +1 -0
- package/dist/enforce/fetch-handler.js +25 -0
- package/dist/enforce/fetch-handler.js.map +1 -0
- package/dist/enforce/index.d.ts +11 -0
- package/dist/enforce/index.d.ts.map +1 -0
- package/dist/enforce/index.js +11 -0
- package/dist/enforce/index.js.map +1 -0
- package/dist/enforce/middleware.d.ts +27 -0
- package/dist/enforce/middleware.d.ts.map +1 -0
- package/dist/enforce/middleware.js +88 -0
- package/dist/enforce/middleware.js.map +1 -0
- package/dist/enforce/next.d.ts +24 -0
- package/dist/enforce/next.d.ts.map +1 -0
- package/dist/enforce/next.js +32 -0
- package/dist/enforce/next.js.map +1 -0
- package/dist/enforce/quote-source.d.ts +31 -0
- package/dist/enforce/quote-source.d.ts.map +1 -0
- package/dist/enforce/quote-source.js +29 -0
- package/dist/enforce/quote-source.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/license.d.ts +16 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +18 -0
- package/dist/license.js.map +1 -0
- package/dist/nonce.d.ts +45 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +122 -0
- package/dist/nonce.js.map +1 -0
- package/dist/pop.d.ts +15 -0
- package/dist/pop.d.ts.map +1 -0
- package/dist/pop.js +77 -0
- package/dist/pop.js.map +1 -0
- package/dist/pricing.d.ts +53 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +44 -0
- package/dist/pricing.js.map +1 -0
- package/dist/revocation.d.ts +6 -0
- package/dist/revocation.d.ts.map +1 -0
- package/dist/revocation.js +45 -0
- package/dist/revocation.js.map +1 -0
- package/package.json +47 -0
package/dist/botAuth.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Bot Auth — RFC 9421 HTTP Message Signatures verifier, scoped to the
|
|
3
|
+
* web-bot-auth profile (draft-meunier-webbotauth-httpsig-protocol-00 +
|
|
4
|
+
* Cloudflare's deployed operational profile).
|
|
5
|
+
*
|
|
6
|
+
* Hand-rolled minimal structured-field subset by design: the full RFC 8941
|
|
7
|
+
* grammar is far more than the three headers need, and the one candidate npm
|
|
8
|
+
* package (web-bot-auth@0.1.x) is a 4-package unaudited tree. This module is
|
|
9
|
+
* self-contained on node:crypto.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
12
|
+
import { isIP } from "node:net";
|
|
13
|
+
/* ------------------------------------------------------------------ *
|
|
14
|
+
* Minimal RFC 8941 structured-field tokenizer — just the productions
|
|
15
|
+
* the three web-bot-auth headers use.
|
|
16
|
+
* ------------------------------------------------------------------ */
|
|
17
|
+
class Cursor {
|
|
18
|
+
s;
|
|
19
|
+
i = 0;
|
|
20
|
+
constructor(s) {
|
|
21
|
+
this.s = s;
|
|
22
|
+
}
|
|
23
|
+
eof() {
|
|
24
|
+
return this.i >= this.s.length;
|
|
25
|
+
}
|
|
26
|
+
peek() {
|
|
27
|
+
return this.s[this.i] ?? "";
|
|
28
|
+
}
|
|
29
|
+
skipSp() {
|
|
30
|
+
while (this.s[this.i] === " " || this.s[this.i] === "\t")
|
|
31
|
+
this.i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** sf-key: lcalpha/'*' then lcalpha DIGIT '_' '-' '.' '*'. */
|
|
35
|
+
function parseKey(c) {
|
|
36
|
+
const start = c.i;
|
|
37
|
+
if (!/[a-z*]/.test(c.peek()))
|
|
38
|
+
return null;
|
|
39
|
+
c.i++;
|
|
40
|
+
while (/[a-z0-9_\-.*]/.test(c.peek()))
|
|
41
|
+
c.i++;
|
|
42
|
+
return c.s.slice(start, c.i);
|
|
43
|
+
}
|
|
44
|
+
/** sf-string: DQUOTE with \\ and \" escapes. */
|
|
45
|
+
function parseQuotedString(c) {
|
|
46
|
+
if (c.peek() !== '"')
|
|
47
|
+
return null;
|
|
48
|
+
c.i++;
|
|
49
|
+
let out = "";
|
|
50
|
+
while (!c.eof()) {
|
|
51
|
+
const ch = c.peek();
|
|
52
|
+
if (ch === '"') {
|
|
53
|
+
c.i++;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
if (ch === "\\") {
|
|
57
|
+
c.i++;
|
|
58
|
+
const esc = c.peek();
|
|
59
|
+
if (esc !== '"' && esc !== "\\")
|
|
60
|
+
return null;
|
|
61
|
+
out += esc;
|
|
62
|
+
c.i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out += ch;
|
|
66
|
+
c.i++;
|
|
67
|
+
}
|
|
68
|
+
return null; // unterminated
|
|
69
|
+
}
|
|
70
|
+
/** Bare item: sf-string | sf-integer | sf-boolean | sf-token (as string). */
|
|
71
|
+
function parseBareItem(c) {
|
|
72
|
+
const ch = c.peek();
|
|
73
|
+
if (ch === '"')
|
|
74
|
+
return parseQuotedString(c);
|
|
75
|
+
if (ch === "?") {
|
|
76
|
+
c.i++;
|
|
77
|
+
const b = c.peek();
|
|
78
|
+
if (b !== "0" && b !== "1")
|
|
79
|
+
return null;
|
|
80
|
+
c.i++;
|
|
81
|
+
return b === "1";
|
|
82
|
+
}
|
|
83
|
+
if (/[\-0-9]/.test(ch)) {
|
|
84
|
+
const start = c.i;
|
|
85
|
+
if (ch === "-")
|
|
86
|
+
c.i++;
|
|
87
|
+
while (/[0-9]/.test(c.peek()))
|
|
88
|
+
c.i++;
|
|
89
|
+
const text = c.s.slice(start, c.i);
|
|
90
|
+
if (!/[0-9]/.test(text[text.length - 1] ?? ""))
|
|
91
|
+
return null;
|
|
92
|
+
return Number(text);
|
|
93
|
+
}
|
|
94
|
+
if (/[A-Za-z*]/.test(ch)) {
|
|
95
|
+
const start = c.i;
|
|
96
|
+
c.i++;
|
|
97
|
+
while (/[A-Za-z0-9!#$%&'*+\-.^_`|~:/]/.test(c.peek()))
|
|
98
|
+
c.i++;
|
|
99
|
+
return c.s.slice(start, c.i);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/** parameters: *( ";" OWS key [ "=" bare-item ] ). */
|
|
104
|
+
function parseParams(c) {
|
|
105
|
+
const out = {};
|
|
106
|
+
while (c.peek() === ";") {
|
|
107
|
+
c.i++;
|
|
108
|
+
c.skipSp();
|
|
109
|
+
const key = parseKey(c);
|
|
110
|
+
if (key === null)
|
|
111
|
+
return null;
|
|
112
|
+
if (c.peek() === "=") {
|
|
113
|
+
c.i++;
|
|
114
|
+
const v = parseBareItem(c);
|
|
115
|
+
if (v === null)
|
|
116
|
+
return null;
|
|
117
|
+
out[key] = v;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
out[key] = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function asString(v) {
|
|
126
|
+
return typeof v === "string" ? v : undefined;
|
|
127
|
+
}
|
|
128
|
+
function asInt(v) {
|
|
129
|
+
return typeof v === "number" && Number.isInteger(v) ? v : undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Parse a `Signature-Input` header: an SF dictionary whose members are inner
|
|
133
|
+
* lists of covered components followed by the signature params. Returns null
|
|
134
|
+
* on any grammar trouble — the caller treats that as an invalid signature.
|
|
135
|
+
*/
|
|
136
|
+
export function parseSignatureInput(value) {
|
|
137
|
+
const c = new Cursor(value);
|
|
138
|
+
const entries = [];
|
|
139
|
+
c.skipSp();
|
|
140
|
+
if (c.eof())
|
|
141
|
+
return null;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const label = parseKey(c);
|
|
144
|
+
if (label === null || c.peek() !== "=")
|
|
145
|
+
return null;
|
|
146
|
+
c.i++;
|
|
147
|
+
const memberStart = c.i;
|
|
148
|
+
if (c.peek() !== "(")
|
|
149
|
+
return null;
|
|
150
|
+
c.i++;
|
|
151
|
+
const components = [];
|
|
152
|
+
for (;;) {
|
|
153
|
+
c.skipSp();
|
|
154
|
+
if (c.peek() === ")") {
|
|
155
|
+
c.i++;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (c.eof())
|
|
159
|
+
return null;
|
|
160
|
+
const compStart = c.i;
|
|
161
|
+
const name = parseQuotedString(c);
|
|
162
|
+
if (name === null)
|
|
163
|
+
return null;
|
|
164
|
+
const itemParams = parseParams(c);
|
|
165
|
+
if (itemParams === null)
|
|
166
|
+
return null;
|
|
167
|
+
const raw = value.slice(compStart, c.i);
|
|
168
|
+
const key = asString(itemParams["key"]);
|
|
169
|
+
components.push(key !== undefined ? { name, key, raw } : { name, raw });
|
|
170
|
+
}
|
|
171
|
+
const params = parseParams(c);
|
|
172
|
+
if (params === null)
|
|
173
|
+
return null;
|
|
174
|
+
entries.push({
|
|
175
|
+
label,
|
|
176
|
+
components,
|
|
177
|
+
params: {
|
|
178
|
+
created: asInt(params["created"]),
|
|
179
|
+
expires: asInt(params["expires"]),
|
|
180
|
+
keyid: asString(params["keyid"]),
|
|
181
|
+
tag: asString(params["tag"]),
|
|
182
|
+
alg: asString(params["alg"]),
|
|
183
|
+
nonce: asString(params["nonce"]),
|
|
184
|
+
},
|
|
185
|
+
rawMemberText: value.slice(memberStart, c.i),
|
|
186
|
+
});
|
|
187
|
+
c.skipSp();
|
|
188
|
+
if (c.eof())
|
|
189
|
+
return entries;
|
|
190
|
+
if (c.peek() !== ",")
|
|
191
|
+
return null;
|
|
192
|
+
c.i++;
|
|
193
|
+
c.skipSp();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
197
|
+
/**
|
|
198
|
+
* Parse a `Signature` header: an SF dictionary of byte sequences
|
|
199
|
+
* (label=:base64:). Returns null on grammar trouble.
|
|
200
|
+
*/
|
|
201
|
+
export function parseSignatureHeader(value) {
|
|
202
|
+
const c = new Cursor(value);
|
|
203
|
+
const out = new Map();
|
|
204
|
+
c.skipSp();
|
|
205
|
+
if (c.eof())
|
|
206
|
+
return null;
|
|
207
|
+
for (;;) {
|
|
208
|
+
const label = parseKey(c);
|
|
209
|
+
if (label === null || c.peek() !== "=")
|
|
210
|
+
return null;
|
|
211
|
+
c.i++;
|
|
212
|
+
if (c.peek() !== ":")
|
|
213
|
+
return null;
|
|
214
|
+
c.i++;
|
|
215
|
+
const end = c.s.indexOf(":", c.i);
|
|
216
|
+
if (end < 0)
|
|
217
|
+
return null;
|
|
218
|
+
const b64 = c.s.slice(c.i, end);
|
|
219
|
+
if (!BASE64_RE.test(b64) || b64.length % 4 !== 0)
|
|
220
|
+
return null;
|
|
221
|
+
out.set(label, new Uint8Array(Buffer.from(b64, "base64")));
|
|
222
|
+
c.i = end + 1;
|
|
223
|
+
c.skipSp();
|
|
224
|
+
if (c.eof())
|
|
225
|
+
return out;
|
|
226
|
+
if (c.peek() !== ",")
|
|
227
|
+
return null;
|
|
228
|
+
c.i++;
|
|
229
|
+
c.skipSp();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Parse a `Signature-Agent` header into the directory URL.
|
|
234
|
+
*
|
|
235
|
+
* Accepts the CF operational profile's plain quoted string (what deployed
|
|
236
|
+
* signers send — CF explicitly REJECTS the dictionary form), tolerates the
|
|
237
|
+
* rev-00 dictionary form (first member wins), and normalizes a bare host to
|
|
238
|
+
* https. Returns null when neither shape parses or the URL is unusable.
|
|
239
|
+
*/
|
|
240
|
+
export function parseSignatureAgent(value) {
|
|
241
|
+
const c = new Cursor(value);
|
|
242
|
+
c.skipSp();
|
|
243
|
+
if (c.eof())
|
|
244
|
+
return null;
|
|
245
|
+
let raw = null;
|
|
246
|
+
if (c.peek() === '"') {
|
|
247
|
+
raw = parseQuotedString(c);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const label = parseKey(c);
|
|
251
|
+
if (label !== null && c.peek() === "=") {
|
|
252
|
+
c.i++;
|
|
253
|
+
raw = parseQuotedString(c);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (raw === null || raw.length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
const normalized = raw.includes("://") ? raw : `https://${raw}`;
|
|
259
|
+
try {
|
|
260
|
+
const url = new URL(normalized);
|
|
261
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
262
|
+
return null;
|
|
263
|
+
if (url.hostname.length === 0)
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return normalized;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Raw member text of dictionary member `key` in an SF dictionary header value
|
|
273
|
+
* — RFC 9421's `;key=` serialization, byte-exact. Null when absent/unparsable.
|
|
274
|
+
*/
|
|
275
|
+
function dictMemberRaw(headerValue, key) {
|
|
276
|
+
const c = new Cursor(headerValue);
|
|
277
|
+
c.skipSp();
|
|
278
|
+
for (;;) {
|
|
279
|
+
const label = parseKey(c);
|
|
280
|
+
if (label === null)
|
|
281
|
+
return null;
|
|
282
|
+
if (c.peek() !== "=")
|
|
283
|
+
return null; // boolean member — not a shape we cover
|
|
284
|
+
c.i++;
|
|
285
|
+
const memberStart = c.i;
|
|
286
|
+
if (c.peek() === ":") {
|
|
287
|
+
// byte sequence
|
|
288
|
+
c.i++;
|
|
289
|
+
const end = c.s.indexOf(":", c.i);
|
|
290
|
+
if (end < 0)
|
|
291
|
+
return null;
|
|
292
|
+
c.i = end + 1;
|
|
293
|
+
}
|
|
294
|
+
else if (parseBareItem(c) === null) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
if (parseParams(c) === null)
|
|
298
|
+
return null;
|
|
299
|
+
if (label === key)
|
|
300
|
+
return c.s.slice(memberStart, c.i);
|
|
301
|
+
c.skipSp();
|
|
302
|
+
if (c.eof())
|
|
303
|
+
return null;
|
|
304
|
+
if (c.peek() !== ",")
|
|
305
|
+
return null;
|
|
306
|
+
c.i++;
|
|
307
|
+
c.skipSp();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Build the RFC 9421 signature base for one Signature-Input entry. Returns
|
|
312
|
+
* null when a covered component is unsupported or unresolvable — the caller
|
|
313
|
+
* treats that as an invalid signature. Supported: `@authority`, `@method`,
|
|
314
|
+
* `@path`, `@target-uri`, plain headers, and dictionary members via `;key=` —
|
|
315
|
+
* everything real web-bot-auth signers cover (CF profile minimum: @authority).
|
|
316
|
+
*/
|
|
317
|
+
export function buildSignatureBase(entry, facts) {
|
|
318
|
+
const lines = [];
|
|
319
|
+
for (const comp of entry.components) {
|
|
320
|
+
let value;
|
|
321
|
+
if (comp.name.startsWith("@")) {
|
|
322
|
+
if (comp.key !== undefined)
|
|
323
|
+
return null;
|
|
324
|
+
switch (comp.name) {
|
|
325
|
+
case "@authority":
|
|
326
|
+
value = facts.authority.toLowerCase();
|
|
327
|
+
break;
|
|
328
|
+
case "@method":
|
|
329
|
+
value = facts.method.toUpperCase();
|
|
330
|
+
break;
|
|
331
|
+
case "@path":
|
|
332
|
+
value = facts.path;
|
|
333
|
+
break;
|
|
334
|
+
case "@target-uri":
|
|
335
|
+
value = facts.targetUri;
|
|
336
|
+
break;
|
|
337
|
+
default:
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const rawHeader = facts.headers[comp.name.toLowerCase()];
|
|
343
|
+
if (rawHeader === undefined)
|
|
344
|
+
return null;
|
|
345
|
+
if (comp.key !== undefined) {
|
|
346
|
+
const member = dictMemberRaw(rawHeader, comp.key);
|
|
347
|
+
if (member === null)
|
|
348
|
+
return null;
|
|
349
|
+
value = member;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
value = rawHeader.trim();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
lines.push(`${comp.raw}: ${value}`);
|
|
356
|
+
}
|
|
357
|
+
lines.push(`"@signature-params": ${entry.rawMemberText}`);
|
|
358
|
+
return lines.join("\n");
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* RFC 7638 JWK thumbprint of an Ed25519 public key (the profile's keyid).
|
|
362
|
+
* Required members in lexicographic order: crv, kty, x.
|
|
363
|
+
*/
|
|
364
|
+
export function jwkThumbprint(x) {
|
|
365
|
+
return createHash("sha256")
|
|
366
|
+
.update(JSON.stringify({ crv: "Ed25519", kty: "OKP", x }))
|
|
367
|
+
.digest("base64url");
|
|
368
|
+
}
|
|
369
|
+
/** Ed25519-verify `sig` over the UTF-8 `base` with a raw JWK x. Never throws. */
|
|
370
|
+
export function verifyEd25519(base, sig, x) {
|
|
371
|
+
try {
|
|
372
|
+
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
|
|
373
|
+
return cryptoVerify(null, Buffer.from(base, "utf8"), key, Buffer.from(sig));
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/* ------------------------------------------------------------------ *
|
|
380
|
+
* Key directory: SSRF-guarded fetch + LRU/TTL cache + single-flight.
|
|
381
|
+
* ------------------------------------------------------------------ */
|
|
382
|
+
const WELL_KNOWN_PATH = "/.well-known/http-message-signatures-directory";
|
|
383
|
+
const DIRECTORY_CONTENT_TYPE = "application/http-message-signatures-directory+json";
|
|
384
|
+
const DIRECTORY_MAX_BYTES = 65536;
|
|
385
|
+
const DIRECTORY_FETCH_TIMEOUT_MS = 3000;
|
|
386
|
+
const DEFAULT_POS_TTL_MS = 6 * 60 * 60 * 1000;
|
|
387
|
+
const DEFAULT_NEG_TTL_MS = 10 * 60 * 1000;
|
|
388
|
+
const DEFAULT_CACHE_CAP = 256;
|
|
389
|
+
const DEFAULT_SKEW_SEC = 10;
|
|
390
|
+
/**
|
|
391
|
+
* LRU + TTL cache of fetched key directories. `null` is a NEGATIVE entry
|
|
392
|
+
* (directory unreachable/unusable) with its own shorter TTL, so a broken
|
|
393
|
+
* operator doesn't get hammered but recovers quickly. One fetch per operator
|
|
394
|
+
* per TTL — verification cost amortizes to zero on the hot path.
|
|
395
|
+
*/
|
|
396
|
+
export class DirectoryCache {
|
|
397
|
+
entries = new Map();
|
|
398
|
+
posTtlMs;
|
|
399
|
+
negTtlMs;
|
|
400
|
+
cap;
|
|
401
|
+
now;
|
|
402
|
+
constructor(opts = {}) {
|
|
403
|
+
this.posTtlMs = opts.posTtlMs ?? DEFAULT_POS_TTL_MS;
|
|
404
|
+
this.negTtlMs = opts.negTtlMs ?? DEFAULT_NEG_TTL_MS;
|
|
405
|
+
this.cap = opts.cap ?? DEFAULT_CACHE_CAP;
|
|
406
|
+
this.now = opts.now ?? Date.now;
|
|
407
|
+
}
|
|
408
|
+
/** undefined = no usable entry; null = cached negative; Map = cached keys. */
|
|
409
|
+
get(url) {
|
|
410
|
+
const e = this.entries.get(url);
|
|
411
|
+
if (!e)
|
|
412
|
+
return undefined;
|
|
413
|
+
const ttl = e.keys === null ? this.negTtlMs : this.posTtlMs;
|
|
414
|
+
if (this.now() - e.at > ttl) {
|
|
415
|
+
this.entries.delete(url);
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
this.entries.delete(url); // LRU refresh
|
|
419
|
+
this.entries.set(url, e);
|
|
420
|
+
return e.keys;
|
|
421
|
+
}
|
|
422
|
+
set(url, keys) {
|
|
423
|
+
this.entries.delete(url);
|
|
424
|
+
this.entries.set(url, { keys, at: this.now() });
|
|
425
|
+
while (this.entries.size > this.cap) {
|
|
426
|
+
const oldest = this.entries.keys().next().value;
|
|
427
|
+
if (oldest === undefined)
|
|
428
|
+
break;
|
|
429
|
+
this.entries.delete(oldest);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Resolve + SSRF-guard the directory URL for a Signature-Agent value. The
|
|
435
|
+
* agent URL is attacker-supplied: HTTPS only, no IP literals, no loopback —
|
|
436
|
+
* except under the explicit local-fixture flag. Null = refused.
|
|
437
|
+
*/
|
|
438
|
+
function directoryUrlFor(agentUrl, allowInsecureHttp) {
|
|
439
|
+
let u;
|
|
440
|
+
try {
|
|
441
|
+
u = new URL(agentUrl);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const hostname = u.hostname.toLowerCase();
|
|
447
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
448
|
+
const isLoopback = hostname === "localhost" || hostname.endsWith(".localhost") || bare === "127.0.0.1" || bare === "::1";
|
|
449
|
+
if (allowInsecureHttp && isLoopback) {
|
|
450
|
+
return { url: `${u.protocol}//${u.host}${WELL_KNOWN_PATH}`, agentHost: hostname };
|
|
451
|
+
}
|
|
452
|
+
if (u.protocol !== "https:")
|
|
453
|
+
return null;
|
|
454
|
+
if (isLoopback || isIP(bare) !== 0)
|
|
455
|
+
return null;
|
|
456
|
+
return { url: `https://${u.host}${WELL_KNOWN_PATH}`, agentHost: hostname };
|
|
457
|
+
}
|
|
458
|
+
/** Read a response body with a hard byte cap. Null = over cap / unreadable. */
|
|
459
|
+
async function readCapped(res, cap) {
|
|
460
|
+
const reader = res.body?.getReader();
|
|
461
|
+
if (!reader) {
|
|
462
|
+
const text = await res.text();
|
|
463
|
+
return Buffer.byteLength(text) > cap ? null : text;
|
|
464
|
+
}
|
|
465
|
+
const chunks = [];
|
|
466
|
+
let total = 0;
|
|
467
|
+
for (;;) {
|
|
468
|
+
const { done, value } = await reader.read();
|
|
469
|
+
if (done)
|
|
470
|
+
break;
|
|
471
|
+
total += value.byteLength;
|
|
472
|
+
if (total > cap) {
|
|
473
|
+
await reader.cancel();
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
chunks.push(value);
|
|
477
|
+
}
|
|
478
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Verify the directory's own signature (tag="http-message-signatures-directory")
|
|
482
|
+
* with the keys it carries — the spec's binding of keys to host. Verified when
|
|
483
|
+
* present; when absent we accept on the strength of the direct-TLS fetch to the
|
|
484
|
+
* well-known host (early operators don't all sign yet — interop over rigor here,
|
|
485
|
+
* revisit when signer coverage telemetry says the fleet signs).
|
|
486
|
+
*/
|
|
487
|
+
function directorySignatureOk(res, dirUrl, keys) {
|
|
488
|
+
const sigInputRaw = res.headers.get("signature-input");
|
|
489
|
+
const sigRaw = res.headers.get("signature");
|
|
490
|
+
if (sigInputRaw === null || sigRaw === null)
|
|
491
|
+
return true; // absent → TLS-bound
|
|
492
|
+
const entries = parseSignatureInput(sigInputRaw);
|
|
493
|
+
const entry = entries?.find((e) => e.params.tag === "http-message-signatures-directory");
|
|
494
|
+
if (!entry)
|
|
495
|
+
return false;
|
|
496
|
+
const sig = parseSignatureHeader(sigRaw)?.get(entry.label);
|
|
497
|
+
if (!sig)
|
|
498
|
+
return false;
|
|
499
|
+
const x = entry.params.keyid !== undefined ? keys.get(entry.params.keyid) : undefined;
|
|
500
|
+
if (x === undefined)
|
|
501
|
+
return false;
|
|
502
|
+
const u = new URL(dirUrl);
|
|
503
|
+
const headers = {};
|
|
504
|
+
res.headers.forEach((v, k) => {
|
|
505
|
+
headers[k.toLowerCase()] = v;
|
|
506
|
+
});
|
|
507
|
+
const base = buildSignatureBase(entry, {
|
|
508
|
+
authority: u.host.toLowerCase(),
|
|
509
|
+
method: "GET",
|
|
510
|
+
path: WELL_KNOWN_PATH,
|
|
511
|
+
targetUri: dirUrl,
|
|
512
|
+
headers,
|
|
513
|
+
});
|
|
514
|
+
if (base === null)
|
|
515
|
+
return false;
|
|
516
|
+
return verifyEd25519(base, sig, x);
|
|
517
|
+
}
|
|
518
|
+
/** Fetch + validate one key directory. Null = unusable (negative-cacheable). */
|
|
519
|
+
async function fetchDirectory(dirUrl, opts) {
|
|
520
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
521
|
+
try {
|
|
522
|
+
const res = await fetchFn(dirUrl, {
|
|
523
|
+
redirect: "manual", // the URL is attacker-supplied — never follow
|
|
524
|
+
signal: AbortSignal.timeout(DIRECTORY_FETCH_TIMEOUT_MS),
|
|
525
|
+
headers: { accept: DIRECTORY_CONTENT_TYPE },
|
|
526
|
+
});
|
|
527
|
+
if (res.status !== 200)
|
|
528
|
+
return null;
|
|
529
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
530
|
+
if (!ct.includes("http-message-signatures-directory+json"))
|
|
531
|
+
return null;
|
|
532
|
+
const body = await readCapped(res, DIRECTORY_MAX_BYTES);
|
|
533
|
+
if (body === null)
|
|
534
|
+
return null;
|
|
535
|
+
const parsed = JSON.parse(body);
|
|
536
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
537
|
+
return null;
|
|
538
|
+
const rawKeys = parsed.keys;
|
|
539
|
+
if (!Array.isArray(rawKeys))
|
|
540
|
+
return null;
|
|
541
|
+
const keys = new Map();
|
|
542
|
+
for (const k of rawKeys) {
|
|
543
|
+
if (typeof k !== "object" || k === null)
|
|
544
|
+
continue;
|
|
545
|
+
const jwk = k;
|
|
546
|
+
if (jwk.kty === "OKP" && jwk.crv === "Ed25519" && typeof jwk.x === "string") {
|
|
547
|
+
keys.set(jwkThumbprint(jwk.x), jwk.x);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (keys.size === 0)
|
|
551
|
+
return null;
|
|
552
|
+
if (!directorySignatureOk(res, dirUrl, keys))
|
|
553
|
+
return null;
|
|
554
|
+
return keys;
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const defaultCache = new DirectoryCache();
|
|
561
|
+
/** Single-flight per directory URL — a burst of signed requests fetches once. */
|
|
562
|
+
const inflight = new Map();
|
|
563
|
+
async function loadDirectory(dirUrl, cache, opts) {
|
|
564
|
+
const running = inflight.get(dirUrl);
|
|
565
|
+
if (running)
|
|
566
|
+
return running;
|
|
567
|
+
const p = fetchDirectory(dirUrl, opts)
|
|
568
|
+
.then((keys) => {
|
|
569
|
+
cache.set(dirUrl, keys);
|
|
570
|
+
return keys;
|
|
571
|
+
})
|
|
572
|
+
.finally(() => {
|
|
573
|
+
inflight.delete(dirUrl);
|
|
574
|
+
});
|
|
575
|
+
inflight.set(dirUrl, p);
|
|
576
|
+
return p;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Verify a request's Web Bot Auth signature, end to end.
|
|
580
|
+
*
|
|
581
|
+
* Outcome discipline (the product decision, not just plumbing):
|
|
582
|
+
* - absent → no signature / not this protocol: the UA path proceeds as if
|
|
583
|
+
* this module didn't exist (byte-identical regression bar).
|
|
584
|
+
* - verified → cryptographic identity: classify() gets `verifiedAgent`.
|
|
585
|
+
* - invalid → a PRESENTED signature failed: still served via the UA path
|
|
586
|
+
* (fail-open), but flagged `sigInvalid` — masquerade telemetry.
|
|
587
|
+
* - unverified → OUR side couldn't verify (directory down, network): fail
|
|
588
|
+
* open with no flag; a verifier outage must never block traffic.
|
|
589
|
+
*
|
|
590
|
+
* No nonce/replay cache in slice 1: the created/expires window (CF profile
|
|
591
|
+
* ~1 minute) bounds replay; a replayed signature buys the same 402 quote.
|
|
592
|
+
*/
|
|
593
|
+
export async function verifyBotAuth(facts, opts = {}) {
|
|
594
|
+
const h = facts.headers;
|
|
595
|
+
const sigInputRaw = h["signature-input"];
|
|
596
|
+
const sigRaw = h["signature"];
|
|
597
|
+
const agentRaw = h["signature-agent"];
|
|
598
|
+
if (sigInputRaw === undefined || sigRaw === undefined || agentRaw === undefined) {
|
|
599
|
+
return { status: "absent" };
|
|
600
|
+
}
|
|
601
|
+
const entries = parseSignatureInput(sigInputRaw);
|
|
602
|
+
if (entries === null)
|
|
603
|
+
return { status: "invalid", reason: "unparsable Signature-Input" };
|
|
604
|
+
const entry = entries.find((e) => e.params.tag === "web-bot-auth");
|
|
605
|
+
if (!entry)
|
|
606
|
+
return { status: "absent" }; // signed, but for some other protocol
|
|
607
|
+
const { created, expires, keyid } = entry.params;
|
|
608
|
+
if (created === undefined || expires === undefined || keyid === undefined) {
|
|
609
|
+
return { status: "invalid", reason: "missing created/expires/keyid" };
|
|
610
|
+
}
|
|
611
|
+
const nowSec = Math.floor((opts.now?.() ?? Date.now()) / 1000);
|
|
612
|
+
const skew = opts.clockSkewSec ?? DEFAULT_SKEW_SEC;
|
|
613
|
+
if (expires < nowSec - skew)
|
|
614
|
+
return { status: "invalid", reason: "signature expired" };
|
|
615
|
+
if (created > nowSec + skew)
|
|
616
|
+
return { status: "invalid", reason: "created in the future" };
|
|
617
|
+
// Replay bound: the draft recommends a 24h max validity; CF's operational
|
|
618
|
+
// profile uses ~1 minute. Without a cap, one captured signed request replays
|
|
619
|
+
// for as long as the signer chose — cap acceptance at the spec's max.
|
|
620
|
+
if (expires - created > 86_400) {
|
|
621
|
+
return { status: "invalid", reason: "validity window exceeds the 24h max" };
|
|
622
|
+
}
|
|
623
|
+
if (!entry.components.some((c) => c.name === "@authority" || c.name === "@target-uri")) {
|
|
624
|
+
return { status: "invalid", reason: "neither @authority nor @target-uri covered" };
|
|
625
|
+
}
|
|
626
|
+
const sig = parseSignatureHeader(sigRaw)?.get(entry.label);
|
|
627
|
+
if (!sig)
|
|
628
|
+
return { status: "invalid", reason: "no signature bytes for the signed label" };
|
|
629
|
+
const agentUrl = parseSignatureAgent(agentRaw);
|
|
630
|
+
if (agentUrl === null)
|
|
631
|
+
return { status: "invalid", reason: "unparsable Signature-Agent" };
|
|
632
|
+
const dir = directoryUrlFor(agentUrl, opts.allowInsecureHttp ?? false);
|
|
633
|
+
if (dir === null)
|
|
634
|
+
return { status: "invalid", reason: "refused directory host (scheme/IP-literal/loopback)" };
|
|
635
|
+
const base = buildSignatureBase(entry, facts);
|
|
636
|
+
if (base === null)
|
|
637
|
+
return { status: "invalid", reason: "unresolvable covered component" };
|
|
638
|
+
const cache = opts.cache ?? defaultCache;
|
|
639
|
+
let keys = cache.get(dir.url);
|
|
640
|
+
const wasCached = keys !== undefined;
|
|
641
|
+
if (keys === undefined)
|
|
642
|
+
keys = await loadDirectory(dir.url, cache, opts);
|
|
643
|
+
if (keys === null)
|
|
644
|
+
return { status: "unverified", reason: "key directory unavailable" };
|
|
645
|
+
let x = keys.get(keyid);
|
|
646
|
+
if (x === undefined && wasCached) {
|
|
647
|
+
// Rotation grace: the cached directory may predate a new key — refetch once.
|
|
648
|
+
keys = await loadDirectory(dir.url, cache, opts);
|
|
649
|
+
if (keys === null)
|
|
650
|
+
return { status: "unverified", reason: "key directory unavailable" };
|
|
651
|
+
x = keys.get(keyid);
|
|
652
|
+
}
|
|
653
|
+
if (x === undefined)
|
|
654
|
+
return { status: "invalid", reason: "keyid not in the operator's directory" };
|
|
655
|
+
if (!verifyEd25519(base, sig, x))
|
|
656
|
+
return { status: "invalid", reason: "signature verification failed" };
|
|
657
|
+
return { status: "verified", agent: { agent: dir.agentHost, keyid } };
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=botAuth.js.map
|