Recipe 11 — Enable bot detection and the challenge: the consensus verdict, the redirect, the proof

Tool Section Level Time Layer Tags
internal/engine/botdef/ · cmd/engine/ Enforcement medium 10 min policy + engine bot, challenge, TR-197

Problem

A credential-stuffing bot spreads login attempts across one source: no single request carries a bad string. You want one response for it: a wall that costs the client work, not a permanent ban. You want evidence of what answered.

Solution

Two surfaces ship the flow: the detector consensus resolves challenge and the engine answers with a signed redirect. The redirect costs one small hash probe. The consensus needs two distinct families at or above the confidence floor.

  1. Generate the signing key and start the engine with it. No key = the challenge verdict refuses (the gate never pretends to challenge):
CHALLENGE_KEY=$(openssl rand -hex 32)
./engine --upstream http://app:8080 \
         --bundle /etc/trishula/bundle.json \
         --challenge-key "$CHALLENGE_KEY" -listen :8080
  1. Point the policy at the wall. The CRD carries the action today: defaultAction: challenge. The bot consensus resolves it when two detector families agree per source:
spec:
  defaultAction: challenge
  1. Probe. The consensus verdict answers 429 with the redirect:
curl -s -i "http://<gateway>/v1/chat/completions"
HTTP/1.1 429 Too Many Requests
Location: /__trishula/challenge?token=<payload>.<hmac>
X-Trishula-Decision: challenge
  1. Read the problem. The hop prints the signed probe: the salt, the difficulty, the problem token. Nothing is consumed by reading it:
curl -s "http://<gateway>/__trishula/challenge?token=<tok>"
problem-token=<tok>
salt=<hex>
diff=16
  1. Solve. The nonce whose sha256(salt:nonce) starts with 16 zero bits is the answer; ~65k hashes, under a second on a laptop. The solve consumes both tokens once and grants the pass cookie:
challenge verified; retry the original request
Set-Cookie: trishula_pass=<token>; HttpOnly; Max-Age=600
  1. Retry the original request with the cookie. The consult reads the grant back and forwards the request for the cookie’s TTL. A client that never solves stays on the wall: every attempt re-answers 429.

Discussion

The token = the payload + the HMAC-SHA256 over it. The expiry lives in the payload (a 10-minute TTL). A nonce marks one-shot use: a consumed token refuses the second solve. A tampered payload, a dropped byte or a wrong key refuses with the named reason in the hop’s log. A past-expiry token refuses identically — the exit criterion “the replay beyond the TTL refuses” holds two ways.

Fail-open stays off here on purpose: a challenge that cannot mint is a deterministic 403, never a silent pass. The proof probe stays small by default (16 bits). The difficulty is a constant in internal/engine/challenge/eow.go until the CRD knob ships.

See also

Provenance

Verified against trishula@97206190 on main, 2026-10-25.

Smoke run, 2026-10-25:

  • go test ./internal/engine/challenge/: the mint/verify round trip, the expiry, the tamper and the wrong key. The run covers the one-shot replay, the peek non-consumption, the work-growth pin.
  • go test ./cmd/engine/ -run TestChallenge: the 429 + the redirect, the verified hop, the tampered hop, the no-key refusal.
  • go test ./cmd/engine/ -run TestEvidenceOfWork: the problem, the wrong answer, the solve, the pass cookie, the forwarded retry. The un-solved retry bounces.
  • CI: run 40172145425 green on PR #258.