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"]}, ) )