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.
- 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- 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- Probe. The consensus verdict answers
429with 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
- 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
- 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
- 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
- spec 12.2 — the challenge verdict in the ladder.
- spec 13.7 — where the challenge sits beside the ban path.
- Issue #239 — the stream. PR #256 · PR #258 — the leaves.
- Recipe 9 — the manual
action: banfor the confirmed offender.
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.