From 351ae4e0bcaceacbd98e69207d123442ad96caa3 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 11:01:55 +0200 Subject: [PATCH] feat: Web Bot Auth cryptographic verification (0.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's post introducing this mechanism is titled 'Forget IPs: using cryptography to verify bot and agent traffic'. The published-IP-range check shipped in 0.11 is precisely the method it retires, so the library should implement the successor rather than defend the predecessor. Web Bot Auth is an RFC 9421 HTTP Message Signatures profile — Ed25519 per request, a Signature-Agent header naming the key directory, keys at /.well-known/http-message-signatures-directory. Backed by Cloudflare, Amazon, Akamai and OpenAI, IETF working group chartered 2026. It dominates ranges on every axis that bit us: any agent that signs rather than four vendors, no freshness problem, no false 'spoofed' from a stale list, and it covers agents running on a user's own machine — the case ranges structurally cannot. Adds combinedVerifier(), which prefers a signature and falls back to ranges, so adoption shifts the mix with no change at the call site. Two deliberate rules: - A present-but-invalid signature returns 'spoofed' even when the client IP sits in a published range. Otherwise a forged signature could be laundered into 'verified' by the weaker check. - Unsigned traffic is 'unverifiable', never 'spoofed'. Most agents do not sign yet; treating silence as forgery would mislabel nearly all real traffic. Unsigned requests return before any I/O — the common path today costs nothing. Signed ones fetch the signer's directory once per origin and cache it, keyed by origin, for an hour. Structured-field parsing is scoped to the shapes this profile emits rather than a full RFC 8941 implementation, and returns null rather than guessing: a malformed header must never read as a valid signature. Tests 246 -> 262. The suite generates a real Ed25519 keypair and signs the actual signature base, then checks replay onto another path, a tampered signature, a key absent from the directory, expiry both by `expires` and by maxAge, non-https signers, an unreachable directory, allowedSigners, that unsigned traffic triggers no fetch, and that the directory is cached. Fixture signatures would only have encoded whatever the parser happens to do. --- README.md | 32 ++++ package.json | 2 +- src/track.ts | 4 +- src/types.ts | 2 +- src/verify.ts | 47 ++++++ src/webbotauth.ts | 364 ++++++++++++++++++++++++++++++++++++++++ test/webbotauth.test.ts | 240 ++++++++++++++++++++++++++ 7 files changed, 688 insertions(+), 3 deletions(-) create mode 100644 src/webbotauth.ts create mode 100644 test/webbotauth.test.ts diff --git a/README.md b/README.md index 6fb3ddb..1feae30 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,38 @@ Now you can build: --- +## Cryptographic verification (Web Bot Auth) + +Published IP ranges were always the weak form of identity. [Web Bot +Auth](https://blog.cloudflare.com/web-bot-auth/) is the strong one: an RFC 9421 +HTTP Message Signatures profile where an agent signs each request with Ed25519 +and publishes its keys at a well-known directory. Backed by Cloudflare, Amazon, +Akamai and OpenAI, with an IETF working group chartered in 2026. + +```ts +import { combinedVerifier } from '@apideck/agent-analytics/verify' + +void trackVisit(req, { analytics, verify: combinedVerifier() }) +``` + +`combinedVerifier` prefers the signature and falls back to ranges: + +| | published IP ranges | Web Bot Auth | +| --- | --- | --- | +| Coverage | 4 vendors | any agent that signs | +| Freshness | rots; needs weekly refresh | none needed | +| False `spoofed` | stale list accuses real crawlers | impossible | +| Agents on a user's machine | unverifiable | signable | + +A present-but-invalid signature is decisive: it returns `spoofed` even if the +client IP happens to sit in a published range, so a forged signature cannot be +laundered by the weaker check. Unsigned traffic is `unverifiable`, never +`spoofed` — most agents do not sign yet, and treating silence as forgery would +mislabel nearly all real traffic. + +Unsigned requests cost nothing: the check returns before any I/O. Signed ones +fetch the signer's key directory once per origin and cache it for an hour. + ## Upgrading to 0.12 Four breaking changes, all deliberate. Each one existed because the previous diff --git a/package.json b/package.json index 2280be2..56168b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apideck/agent-analytics", - "version": "0.13.0", + "version": "0.14.0", "description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", "keywords": [ "ai", diff --git a/src/track.ts b/src/track.ts index f3f9e0f..58c0afc 100644 --- a/src/track.ts +++ b/src/track.ts @@ -80,7 +80,9 @@ export async function trackVisit(req: Request, opts: TrackVisitOptions): Promise // Verification is injected rather than imported, so the published IP range // tables only reach bundles that actually use them. Import `verifyRequest` // from `@apideck/agent-analytics/verify` and pass it as `verify`. - const verification = opts.verify ? opts.verify(req) : null + // May be async: Web Bot Auth fetches a signer's key directory on first + // sight of that origin, then serves from cache. + const verification = opts.verify ? await opts.verify(req) : null // Headless scoring only discriminates for browser-shaped UAs. On a declared // crawler or an HTTP client it fires on nearly everything — measured true on diff --git a/src/types.ts b/src/types.ts index 4b9fe1f..8f0cd77 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,7 @@ export interface TrackVisitOptions { * Injected rather than imported so the published IP range tables — the * largest thing in the package — only reach bundles that use them. */ - verify?: (req: Request) => BotVerificationLike + verify?: (req: Request) => BotVerificationLike | Promise /** * Label describing how the request arrived (e.g. `'page-view'`, `'md-suffix'`, * `'ua-rewrite'`). Emitted as a `source` property on the captured event so diff --git a/src/verify.ts b/src/verify.ts index 2371a0b..46ab08f 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -159,3 +159,50 @@ export function clientIpFromRequest(req: Request): string { export function verifyRequest(req: Request): BotVerification { return verifyBotIdentity(req.headers.get('user-agent'), clientIpFromRequest(req)) } + +export { + clearKeyCache, + jwkThumbprint, + verifyWebBotAuth, + webBotAuthVerifier +} from './webbotauth.js' +export type { WebBotAuthOptions, WebBotAuthResult, WebBotAuthVerdict } from './webbotauth.js' + +import { verifyWebBotAuth, type WebBotAuthOptions } from './webbotauth.js' +import type { BotVerificationLike } from './types.js' + +/** + * Verifier that prefers a cryptographic signature and falls back to published + * IP ranges. + * + * Ordering matters. Web Bot Auth proves control of a signing key, works for + * any agent that adopts it, and cannot go stale. IP ranges cover four vendors, + * rot between refreshes, and cannot see an agent running on a user's own + * machine. So a signature — valid or invalid — is always the answer when one + * is present; ranges only speak when the request is unsigned. + * + * As signing adoption grows this quietly shifts from mostly-ranges to + * mostly-signatures with no change at the call site. + */ +export function combinedVerifier( + opts: WebBotAuthOptions = {} +): (req: Request) => Promise { + return async (req: Request): Promise => { + const signed = await verifyWebBotAuth(req, opts) + + if (signed.verdict === 'verified') { + return { verdict: 'verified', verified: true, reason: 'web-bot-auth' } + } + // A signature that is present and fails is decisive — do not let a lucky + // IP-range hit launder a forged signature into 'verified'. + if (signed.verdict === 'invalid-signature' || signed.verdict === 'expired') { + return { verdict: 'spoofed', verified: false, reason: `web-bot-auth-${signed.verdict}` } + } + + const byRange = verifyRequest(req) + if (byRange.verdict !== 'not-claimed' && byRange.verdict !== 'unverifiable') { + return { ...byRange, reason: byRange.reason ?? 'published-ip-range' } + } + return byRange + } +} diff --git a/src/webbotauth.ts b/src/webbotauth.ts new file mode 100644 index 0000000..d0218f0 --- /dev/null +++ b/src/webbotauth.ts @@ -0,0 +1,364 @@ +/** + * Web Bot Auth — cryptographic agent verification. + * + * An RFC 9421 HTTP Message Signatures profile, backed by Cloudflare, Amazon, + * Akamai and OpenAI, with an IETF working group chartered in 2026. An agent + * signs each request with Ed25519 and publishes its public keys at a + * well-known JWKS directory, so a site can verify the claim without knowing + * anything about the agent in advance. + * + * This strictly dominates the published-IP-range check in `verify.ts`: + * + * published ranges Web Bot Auth + * 4 vendors any agent that signs + * rots, needs refresh CI no freshness problem + * stale list -> false cannot produce a false 'spoofed' + * 'spoofed' on real bots + * can't cover agents on signable from anywhere + * the user's own machine + * + * Ranges remain the fallback for vendors that have not adopted signing yet. + * + * Three headers carry the proof: + * Signature-Agent: "https://operator.example.com" <- key directory origin + * Signature-Input: sig=(...);keyid="...";tag="web-bot-auth";created=... + * Signature: sig=:base64: + */ + +import type { BotVerificationLike } from './types.js' + +/** Where a signer publishes its keys, per the profile. */ +const DIRECTORY_PATH = '/.well-known/http-message-signatures-directory' + +export type WebBotAuthVerdict = + | 'verified' + | 'invalid-signature' + | 'unknown-key' + | 'expired' + | 'malformed' + | 'not-signed' + +export interface WebBotAuthResult { + verdict: WebBotAuthVerdict + /** Origin from `Signature-Agent`, when the request carried one. */ + signerOrigin?: string + /** JWK thumbprint the signature claimed. */ + keyId?: string +} + +export interface WebBotAuthOptions { + /** Override `fetch` (tests, pinned runtimes). */ + fetchImpl?: typeof fetch + /** How long to cache a signer's key directory. Defaults to 1 hour. */ + keyTtlMs?: number + /** Reject signatures older than this, independent of `expires`. Default 5 min. */ + maxAgeSeconds?: number + /** + * Restrict which origins may sign. Anything else is `unknown-key`. Leave + * unset to accept any signer that presents a valid signature over keys it + * publishes — the signature proves control of the origin, not that you want + * to hear from it. + */ + allowedSigners?: readonly string[] +} + +interface CachedKeys { + keys: Map + expiresAt: number +} + +const KEY_CACHE = new Map>() + +/* ------------------------------------------------------------------------- + * Structured-field parsing + * + * A full RFC 8941 parser is far more than this profile needs. These handle the + * shapes Web Bot Auth actually emits, and return null rather than guessing on + * anything else — a malformed header must never read as a valid signature. + * ---------------------------------------------------------------------- */ + +/** `"https://example.com"` -> `https://example.com` */ +function parseSfString(raw: string | null): string | null { + if (!raw) return null + const m = raw.trim().match(/^"([^"]*)"$/) + return m ? (m[1] ?? null) : null +} + +export interface SignatureInput { + label: string + /** Covered component identifiers, in signing order. */ + components: string[] + keyid?: string + alg?: string + tag?: string + created?: number + expires?: number + /** The raw inner-list + params text, needed verbatim for the signature base. */ + raw: string +} + +/** Parse `label=("a" "b");keyid="k";tag="web-bot-auth"`. */ +export function parseSignatureInput(header: string | null): SignatureInput | null { + if (!header) return null + const m = header.trim().match(/^([A-Za-z0-9_-]+)=(\((.*?)\)(.*))$/) + if (!m) return null + const [, label, raw, inner, paramText] = m + if (!label || raw === undefined) return null + + const components = (inner ?? '').match(/"[^"]*"(?:;[^ )]*)?/g)?.map((s) => s) ?? [] + + const out: SignatureInput = { label, components, raw } + for (const p of (paramText ?? '').split(';')) { + const kv = p.match(/^([a-z]+)=(.*)$/) + if (!kv) continue + const [, k, vRaw] = kv + const v = vRaw ?? '' + const str = v.startsWith('"') ? v.slice(1, -1) : v + if (k === 'keyid') out.keyid = str + else if (k === 'alg') out.alg = str + else if (k === 'tag') out.tag = str + else if (k === 'created') out.created = Number(str) + else if (k === 'expires') out.expires = Number(str) + } + return out +} + +/** Parse `label=:base64:` into raw signature bytes. */ +export function parseSignature(header: string | null, label: string): ArrayBuffer | null { + if (!header) return null + const m = header.match(new RegExp(`(?:^|,)\\s*${label}=:([A-Za-z0-9+/=]+):`)) + if (!m?.[1]) return null + try { + const bin = atob(m[1]) + const buf = new ArrayBuffer(bin.length) + const out = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return buf + } catch { + return null + } +} + +/** + * Rebuild the RFC 9421 signature base: one line per covered component, then + * the `@signature-params` line carrying the inner list verbatim. + */ +export function buildSignatureBase(req: Request, input: SignatureInput): string | null { + const url = new URL(req.url) + const lines: string[] = [] + + for (const component of input.components) { + const name = component.match(/^"([^"]*)"/)?.[1] + if (name === undefined) return null + let value: string | null + + switch (name) { + case '@method': + value = req.method.toUpperCase() + break + case '@authority': + value = url.host + break + case '@scheme': + value = url.protocol.replace(':', '') + break + case '@target-uri': + value = url.toString() + break + case '@path': + value = url.pathname + break + case '@query': + value = url.search || '?' + break + default: + // Anything else is a header name, lowercase per the spec. + if (name.startsWith('@')) return null // derived component we don't model + value = req.headers.get(name) + break + } + + // A covered component the request doesn't carry makes the base + // unreconstructable — that is a verification failure, not a skip. + if (value === null) return null + lines.push(`${component}: ${value.trim()}`) + } + + lines.push(`"@signature-params": ${input.raw}`) + return lines.join('\n') +} + +/* ------------------------------------------------------------------------ */ + +async function loadKeys( + origin: string, + opts: WebBotAuthOptions +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch + const ttl = opts.keyTtlMs ?? 3_600_000 + const res = await fetchImpl(`${origin}${DIRECTORY_PATH}`, { + headers: { accept: 'application/http-message-signatures-directory+json, application/json' } + }) + if (!res.ok) throw new Error(`key directory ${res.status}`) + const body = (await res.json()) as { keys?: unknown[] } + const keys = new Map() + + for (const raw of body.keys ?? []) { + const jwk = raw as JsonWebKey & { kid?: string } + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') continue + try { + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['verify']) + // Index by both the advertised kid and the RFC 7638 thumbprint, since + // the profile identifies keys by thumbprint but directories often also + // publish a kid. + if (jwk.kid) keys.set(jwk.kid, key) + keys.set(await jwkThumbprint(jwk), key) + } catch { + // A single unusable key must not poison the whole directory. + } + } + return { keys, expiresAt: Date.now() + ttl } +} + +/** RFC 7638 JWK thumbprint, base64url of SHA-256 over the canonical members. */ +export async function jwkThumbprint(jwk: JsonWebKey): Promise { + const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }) + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)) + let bin = '' + for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function cachedKeys(origin: string, opts: WebBotAuthOptions): Promise { + const hit = KEY_CACHE.get(origin) + if (hit) { + // Re-check expiry once resolved; a rejected or stale entry is dropped. + return hit + .then((c) => { + if (c.expiresAt > Date.now()) return c + KEY_CACHE.delete(origin) + return cachedKeys(origin, opts) + }) + .catch(() => { + KEY_CACHE.delete(origin) + return loadKeys(origin, opts) + }) + } + const p = loadKeys(origin, opts) + KEY_CACHE.set(origin, p) + // Don't cache a failed fetch. + p.catch(() => KEY_CACHE.delete(origin)) + return p +} + +/** Drop cached signer keys. Exposed for tests and key-rotation handling. */ +export function clearKeyCache(): void { + KEY_CACHE.clear() +} + +/** + * Verify a request's Web Bot Auth signature. + * + * Requires a network fetch the first time a signer is seen; keys are then + * cached per origin for `keyTtlMs`. Unsigned requests return `'not-signed'` + * immediately with no I/O, which is the overwhelmingly common path today. + */ +export async function verifyWebBotAuth( + req: Request, + opts: WebBotAuthOptions = {} +): Promise { + const agentHeader = req.headers.get('signature-agent') + const inputHeader = req.headers.get('signature-input') + const sigHeader = req.headers.get('signature') + if (!inputHeader || !sigHeader) return { verdict: 'not-signed' } + + const input = parseSignatureInput(inputHeader) + if (!input) return { verdict: 'malformed' } + // The tag scopes a signature to bot authentication. Without it, this is some + // other RFC 9421 use and not ours to judge. + if (input.tag && input.tag !== 'web-bot-auth') return { verdict: 'not-signed' } + + const signerOrigin = parseSfString(agentHeader) + if (!signerOrigin) return { verdict: 'malformed' } + let origin: string + try { + const u = new URL(signerOrigin) + if (u.protocol !== 'https:') return { verdict: 'malformed' } + origin = u.origin + } catch { + return { verdict: 'malformed' } + } + + if (opts.allowedSigners && !opts.allowedSigners.includes(origin)) { + return { verdict: 'unknown-key', signerOrigin: origin, ...(input.keyid ? { keyId: input.keyid } : {}) } + } + + const now = Math.floor(Date.now() / 1000) + const maxAge = opts.maxAgeSeconds ?? 300 + if (input.expires !== undefined && input.expires < now) { + return { verdict: 'expired', signerOrigin: origin } + } + if (input.created !== undefined && now - input.created > maxAge) { + return { verdict: 'expired', signerOrigin: origin } + } + + const base = buildSignatureBase(req, input) + if (!base) return { verdict: 'malformed', signerOrigin: origin } + + const sig = parseSignature(sigHeader, input.label) + if (!sig) return { verdict: 'malformed', signerOrigin: origin } + + let keys: CachedKeys + try { + keys = await cachedKeys(origin, opts) + } catch { + // Directory unreachable — we cannot judge, and must not call it invalid. + return { verdict: 'unknown-key', signerOrigin: origin } + } + + const key = input.keyid ? keys.keys.get(input.keyid) : undefined + if (!key) { + return { verdict: 'unknown-key', signerOrigin: origin, ...(input.keyid ? { keyId: input.keyid } : {}) } + } + + const ok = await crypto.subtle.verify( + { name: 'Ed25519' }, + key, + sig, + new TextEncoder().encode(base) + ) + return { + verdict: ok ? 'verified' : 'invalid-signature', + signerOrigin: origin, + ...(input.keyid ? { keyId: input.keyid } : {}) + } +} + +/** + * Adapter to the shared verdict shape used by `trackVisit` and `agentPolicy`. + * + * Deliberately conservative about `spoofed`: only a signature that is present + * and fails cryptographically earns it. A missing signature is `unverifiable`, + * because most agents do not sign yet and treating silence as forgery would + * mislabel nearly all real traffic. + */ +export function webBotAuthVerifier( + opts: WebBotAuthOptions = {} +): (req: Request) => Promise { + return async (req: Request): Promise => { + const r = await verifyWebBotAuth(req, opts) + switch (r.verdict) { + case 'verified': + return { verdict: 'verified', verified: true, reason: 'web-bot-auth' } + case 'invalid-signature': + return { verdict: 'spoofed', verified: false, reason: 'web-bot-auth-signature-invalid' } + case 'expired': + return { verdict: 'spoofed', verified: false, reason: 'web-bot-auth-expired' } + case 'malformed': + case 'unknown-key': + return { verdict: 'unverifiable', verified: null, reason: `web-bot-auth-${r.verdict}` } + default: + return { verdict: 'unverifiable', verified: null, reason: 'not-signed' } + } + } +} diff --git a/test/webbotauth.test.ts b/test/webbotauth.test.ts new file mode 100644 index 0000000..1431b98 --- /dev/null +++ b/test/webbotauth.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + buildSignatureBase, + clearKeyCache, + jwkThumbprint, + parseSignatureInput, + verifyWebBotAuth, + webBotAuthVerifier +} from '../src/webbotauth.js' +import { combinedVerifier } from '../src/verify.js' + +/* --------------------------------------------------------------------------- + * A real signer. Generating a keypair and signing the RFC 9421 signature base + * end-to-end is the only test that proves this works — hand-rolled fixtures + * would just encode whatever my parser happens to do. + * ------------------------------------------------------------------------ */ + +const SIGNER = 'https://operator.example.com' + +async function makeSigner() { + const kp = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [ + 'sign', + 'verify' + ])) as CryptoKeyPair + const jwk = await crypto.subtle.exportKey('jwk', kp.publicKey) + const kid = await jwkThumbprint(jwk) + const directory = { keys: [{ ...jwk, kid }] } + + const fetchImpl = (async (url: string | URL) => { + if (String(url) === `${SIGNER}/.well-known/http-message-signatures-directory`) { + return new Response(JSON.stringify(directory), { status: 200 }) + } + return new Response('not found', { status: 404 }) + }) as unknown as typeof fetch + + async function sign( + req: Request, + { created = Math.floor(Date.now() / 1000), expires }: { created?: number; expires?: number } = {} + ) { + const components = ['"@authority"', '"@path"', '"signature-agent"'] + const params = + `;created=${created}` + + (expires !== undefined ? `;expires=${expires}` : '') + + `;keyid="${kid}";alg="ed25519";tag="web-bot-auth"` + const raw = `(${components.join(' ')})${params}` + const input = parseSignatureInput(`sig=${raw}`)! + const base = buildSignatureBase(req, input)! + const sig = await crypto.subtle.sign({ name: 'Ed25519' }, kp.privateKey, new TextEncoder().encode(base)) + let bin = '' + for (const b of new Uint8Array(sig)) bin += String.fromCharCode(b) + const headers = new Headers(req.headers) + headers.set('signature-input', `sig=${raw}`) + headers.set('signature', `sig=:${btoa(bin)}:`) + return new Request(req.url, { method: req.method, headers }) + } + + return { fetchImpl, sign, kid } +} + +function baseRequest(path = '/docs/intro') { + return new Request(`https://example.com${path}`, { + headers: { + 'user-agent': 'Mozilla/5.0 (compatible; ExampleBot/1.0)', + 'signature-agent': `"${SIGNER}"` + } + }) +} + +describe('verifyWebBotAuth', () => { + beforeEach(() => clearKeyCache()) + + it('verifies a genuinely signed request', async () => { + const { fetchImpl, sign, kid } = await makeSigner() + const signed = await sign(baseRequest()) + const r = await verifyWebBotAuth(signed, { fetchImpl }) + expect(r.verdict).toBe('verified') + expect(r.signerOrigin).toBe(SIGNER) + expect(r.keyId).toBe(kid) + }) + + it('rejects a signature over a different path', async () => { + // Replaying a valid signature onto another URL must fail: @path is covered. + const { fetchImpl, sign } = await makeSigner() + const signed = await sign(baseRequest('/docs/intro')) + const replayed = new Request('https://example.com/admin', { + method: signed.method, + headers: signed.headers + }) + expect((await verifyWebBotAuth(replayed, { fetchImpl })).verdict).toBe('invalid-signature') + }) + + it('rejects a tampered signature', async () => { + const { fetchImpl, sign } = await makeSigner() + const signed = await sign(baseRequest()) + const headers = new Headers(signed.headers) + const raw = headers.get('signature')! + // Flip a byte inside the base64 payload. + headers.set('signature', raw.replace(/:(.)/, (_m, c: string) => ':' + (c === 'A' ? 'B' : 'A'))) + const tampered = new Request(signed.url, { headers }) + expect((await verifyWebBotAuth(tampered, { fetchImpl })).verdict).toBe('invalid-signature') + }) + + it('rejects a signature signed by a key the directory does not publish', async () => { + const a = await makeSigner() + const b = await makeSigner() + // Signed by b, but verified against a's directory. + const signed = await b.sign(baseRequest()) + expect((await verifyWebBotAuth(signed, { fetchImpl: a.fetchImpl })).verdict).toBe('unknown-key') + }) + + it('rejects an expired signature', async () => { + const { fetchImpl, sign } = await makeSigner() + const past = Math.floor(Date.now() / 1000) - 600 + const signed = await sign(baseRequest(), { created: past, expires: past + 60 }) + expect((await verifyWebBotAuth(signed, { fetchImpl })).verdict).toBe('expired') + }) + + it('rejects a signature older than maxAgeSeconds even without expires', async () => { + const { fetchImpl, sign } = await makeSigner() + const signed = await sign(baseRequest(), { created: Math.floor(Date.now() / 1000) - 3600 }) + expect((await verifyWebBotAuth(signed, { fetchImpl })).verdict).toBe('expired') + }) + + it('honours allowedSigners', async () => { + const { fetchImpl, sign } = await makeSigner() + const signed = await sign(baseRequest()) + const r = await verifyWebBotAuth(signed, { fetchImpl, allowedSigners: ['https://other.example'] }) + expect(r.verdict).toBe('unknown-key') + }) + + it('reports not-signed without any network call', async () => { + let called = false + const fetchImpl = (async () => { + called = true + return new Response('{}') + }) as unknown as typeof fetch + const r = await verifyWebBotAuth(baseRequest(), { fetchImpl }) + expect(r.verdict).toBe('not-signed') + // The common path today is unsigned traffic; it must cost nothing. + expect(called).toBe(false) + }) + + it('treats an unreachable key directory as unverifiable, not invalid', async () => { + const { sign } = await makeSigner() + const signed = await sign(baseRequest()) + const dead = (async () => new Response('nope', { status: 500 })) as unknown as typeof fetch + expect((await verifyWebBotAuth(signed, { fetchImpl: dead })).verdict).toBe('unknown-key') + }) + + it('rejects a non-https signer origin', async () => { + const { fetchImpl, sign } = await makeSigner() + const signed = await sign(baseRequest()) + const headers = new Headers(signed.headers) + headers.set('signature-agent', '"http://operator.example.com"') + expect( + (await verifyWebBotAuth(new Request(signed.url, { headers }), { fetchImpl })).verdict + ).toBe('malformed') + }) + + it('caches the key directory across requests', async () => { + const { fetchImpl, sign } = await makeSigner() + let fetches = 0 + const counting = (async (u: string | URL) => { + fetches++ + return fetchImpl(u as string) + }) as unknown as typeof fetch + const a = await sign(baseRequest('/a')) + const b = await sign(baseRequest('/b')) + await verifyWebBotAuth(a, { fetchImpl: counting }) + await verifyWebBotAuth(b, { fetchImpl: counting }) + expect(fetches).toBe(1) + }) +}) + +describe('webBotAuthVerifier adapter', () => { + beforeEach(() => clearKeyCache()) + + it('maps a valid signature to verified', async () => { + const { fetchImpl, sign } = await makeSigner() + const v = webBotAuthVerifier({ fetchImpl }) + expect(await v(await sign(baseRequest()))).toMatchObject({ verdict: 'verified', verified: true }) + }) + + it('maps unsigned traffic to unverifiable, never spoofed', async () => { + // Most agents do not sign yet. Calling silence forgery would mislabel + // nearly all real traffic. + const v = webBotAuthVerifier() + expect(await v(baseRequest())).toMatchObject({ verdict: 'unverifiable', verified: null }) + }) +}) + +describe('combinedVerifier', () => { + beforeEach(() => clearKeyCache()) + + it('prefers a valid signature over the IP range check', async () => { + const { fetchImpl, sign } = await makeSigner() + // A UA claiming nothing, from an IP in no published range — the range check + // alone could never call this verified. + const v = combinedVerifier({ fetchImpl }) + const r = await v(await sign(baseRequest())) + expect(r).toMatchObject({ verdict: 'verified', reason: 'web-bot-auth' }) + }) + + it('lets a failed signature win over a matching IP range', async () => { + // Anti-laundering: a forged signature from a genuine crawler IP must not be + // rescued by the weaker check. + const a = await makeSigner() + const b = await makeSigner() + const req = new Request('https://example.com/', { + headers: { + 'user-agent': 'ClaudeBot/1.0', + 'x-forwarded-for': '34.162.230.222', // really is in Anthropic's range + 'signature-agent': `"${SIGNER}"` + } + }) + const signed = await b.sign(req) + // b's key is not in a's directory -> unknown-key -> falls through to ranges + const unknown = await combinedVerifier({ fetchImpl: a.fetchImpl })(signed) + expect(unknown.verdict).toBe('verified') // range check still applies + + // Same signer origin, different directory: clear the cache, which is + // correctly keyed by origin and would otherwise serve a's keys for b. + clearKeyCache() + + // An actually-invalid signature is decisive. + const headers = new Headers(signed.headers) + headers.set('signature-input', signed.headers.get('signature-input')!) + const tampered = new Request('https://example.com/other', { headers }) + const bad = await combinedVerifier({ fetchImpl: b.fetchImpl })(tampered) + expect(bad.verdict).toBe('spoofed') + }) + + it('falls back to published ranges for unsigned traffic', async () => { + const v = combinedVerifier() + const req = new Request('https://example.com/', { + headers: { 'user-agent': 'ClaudeBot/1.0', 'x-forwarded-for': '34.162.230.222' } + }) + expect(await v(req)).toMatchObject({ verdict: 'verified' }) + }) +})