From 6d517a026957f9f2a7ede159e0f49c5171e87c6f Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Fri, 18 Sep 2026 04:24:28 +0000 Subject: [PATCH] Finish the rename the adversarial review found half-done #240 renamed identifiers but not the prose around them, so five places still named a script that no longer exists and five described a security model D1 disproved. The one that mattered: the beta deploy script's failure message told the operator to run `./bin/make-human-token-keypair.py`. That message fires at exactly the moment someone is blocked mid-deploy, and the command it gave them would have failed with "No such file". Same stale command in compose.yaml, docker-compose.yml, pyproject.toml and the generator's own usage line. "proof-of-human" is gone from .gitignore, pyproject.toml, FR-003, the generator and the startup error. The token asserts caller identity for one visit; saying otherwise in an error message is worse than saying nothing, because it sends whoever reads it looking for a captcha. Also documented, rather than quietly left: `"aud"` in the required claims is redundant today. Removing it fails no test, because PyJWT already raises MissingRequiredClaimError for an absent `aud` when an audience is expected. It stays as belt-and-braces against that behaviour changing, and the comment says so, so the next person does not spend time working out which line is load-bearing. Verified the preflight both ways by extracting the block and running it against a repo with the key and one without, rather than running the deploy script and touching beta. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 +- bin/make-caller-token-keypair.py | 9 +++++---- compose.yaml | 2 +- docker-compose.yml | 2 +- pyproject.toml | 4 ++-- specs/010-search-page-answers/spec.md | 2 +- src/util/caller_token.py | 8 +++++++- 7 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 4acf2c3..0a018e8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/bin/make-caller-token-keypair.py b/bin/make-caller-token-keypair.py index d394b25..f0332c6 100755 --- a/bin/make-caller-token-keypair.py +++ b/bin/make-caller-token-keypair.py @@ -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 diff --git a/compose.yaml b/compose.yaml index 181953e..0329d8f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 488996f..132121c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a57dff4..0baad42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/specs/010-search-page-answers/spec.md b/specs/010-search-page-answers/spec.md index 61fc6b0..2ea293f 100644 --- a/specs/010-search-page-answers/spec.md +++ b/specs/010-search-page-answers/spec.md @@ -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 diff --git a/src/util/caller_token.py b/src/util/caller_token.py index 486e15e..3e4c949 100644 --- a/src/util/caller_token.py +++ b/src/util/caller_token.py @@ -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) @@ -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"]}, ) )