Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ records/
config.yml
data/

# Key material. The answer endpoint verifies a proof-of-human token against a
# Key material. The answer endpoint verifies the website's caller token against a
# public key, and whoever mints those tokens holds the private half. Neither
# belongs in the repository.
*.pem
Expand Down
9 changes: 5 additions & 4 deletions bin/make-caller-token-keypair.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
#!/usr/bin/env python3
"""Generate the keypair the answer endpoint verifies proof-of-human tokens with.
"""Generate the keypair the answer endpoint verifies the website's caller tokens with.

The endpoint holds only the **public** half, and verifies EdDSA or RS256 -- never
an HMAC algorithm, so a stolen public key cannot be turned into a signing key.
Whoever mints tokens holds the private half; who that is is D1 in
specs/010-search-page-answers, still open.
The website mints and holds the private half (D1, decided 2026-09-18). The token
asserts caller identity for one visit, not that a human is present -- there is no
human gate on the search path.

Rotation is this script plus a restart: generate, replace the public key the
service reads, hand the private half to the minter. Tokens signed by the old key
stop verifying immediately, which is the point.

Usage:
./bin/make-human-token-keypair.py deploy/beta
./bin/make-caller-token-keypair.py deploy/beta
"""

import stat
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ secrets:
# The public half of the answer endpoint's token-verifying keypair.
# External like the rest, so no key material lives in the repository:
# docker secret create CALLER_TOKEN_PUBLIC_KEY deploy/beta/caller_token_public.pem
# Generate the pair with ./bin/make-human-token-keypair.py deploy/beta
# Generate the pair with ./bin/make-caller-token-keypair.py deploy/beta
CALLER_TOKEN_PUBLIC_KEY:
external: true
CHAINLIT_AUTH_SECRET:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,6 @@ secrets:
# root-owned directory at that path, which then needs sudo to clear and which
# the key generator would refuse to overwrite. A missing secret just errors.
#
# Generate it with ./bin/make-human-token-keypair.py deploy/beta
# Generate it with ./bin/make-caller-token-keypair.py deploy/beta
caller_token_public.pem:
file: ./deploy/beta/caller_token_public.pem
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ packages = [
[tool.poetry.dependencies]
python = ">=3.12, <4"
langchain = "^1.4.0"
# Declared, not inherited. The answer endpoint verifies a signed proof-of-human
# Declared, not inherited. The answer endpoint verifies a signed caller
# token, so this repository imports PyJWT directly. It arrives transitively today
# via chainlit and mcp, which is not a dependency -- it is a coincidence that a
# chainlit upgrade could end, and the symptom would be an endpoint that cannot
# verify anyone.
pyjwt = {version = "^2.10", extras = ["crypto"]}
# Imported directly by bin/make-human-token-keypair.py and the token tests,
# Imported directly by bin/make-caller-token-keypair.py and the token tests,
# not just pulled in by pyjwt's crypto extra. Declared so it does not vanish
# if that extra or the JWT library ever changes.
cryptography = ">=42,<51"
Expand Down
2 changes: 1 addition & 1 deletion specs/010-search-page-answers/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ classifier's decision matches, and that no LLM answer call happens for the latte
serves only the captcha pages and a landing page, and Chainlit owns the conversation
over websockets
- **FR-002**: The response MUST stream, so partial text can render before completion
- **FR-003**: The endpoint MUST refuse any request without a valid proof-of-human
- **FR-003**: The endpoint MUST refuse any request without a valid caller
token, before any model call
- **FR-004**: Citations MUST be Reactome stable IDs, so the website can render links
in its own style rather than parsing prose
Expand Down
8 changes: 7 additions & 1 deletion src/util/caller_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def load_verifying_key(path: str | None = None) -> str:
if not configured:
raise RuntimeError(
f"{KEY_PATH_ENV} is not set. The answer endpoint verifies a signed "
"proof-of-human token and cannot run without a verifying key; starting "
"caller token and cannot run without a verifying key; starting "
"without one would accept every request."
)
key_file = Path(configured)
Expand Down Expand Up @@ -107,6 +107,12 @@ def verify(token: str, verifying_key: str, *, audience: str | None = None) -> di
verifying_key,
algorithms=ALGORITHMS,
audience=expected,
# "aud" here is belt-and-braces, and deliberately kept despite
# being redundant today: PyJWT already raises
# MissingRequiredClaimError for an absent `aud` when an audience
# is expected, so removing it fails no test. It is here so that
# behaviour changing in a future PyJWT cannot quietly turn "no
# audience" into "nothing to check".
options={"require": ["exp", "aud"]},
)
)
Expand Down
Loading