From 20c370efa7c1924f3fc324060f26faacf10c3655 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Fri, 18 Sep 2026 03:45:01 +0000 Subject: [PATCH 1/2] Provision the verifying key, so deploying the endpoint cannot take /chat down `bin/chat-fastapi.py` raises at startup when HUMAN_TOKEN_PUBLIC_KEY_PATH is missing -- deliberately, because an endpoint that accepts everything is worse than one that is down. Chainlit is mounted on the same app, so that failure takes `/chat` with it, and the key was configured nowhere: not in compose, not in the beta env, not in SECRET_NAMES. Deploying current main to beta would have stopped the working chat. Verified against the published image for main (a6fa184), on a spare port with beta untouched: - with the key mounted: starts, and a valid token gets an answer -- 12 citations, first token 14.0s, no anchors. Missing and expired tokens are refused in 0.0s - without it: exits 3, `Cannot read the verifying key at /run/secrets/human_token_public.pem` So `~/update-beta-chat.sh` now checks the key is readable *before* it stops the running container, alongside its existing image checks, and mounts it read-only at /run/secrets. A missing key now fails the update with an instruction rather than a stopped service. `*.pem` and `*.key` are ignored first, before any key existed, so neither half can be committed. The public key is 0644 because the image runs as appuser and must read it -- checked in a throwaway container. The private half is only for whoever mints tokens, which is D1 and still open. Rotation is the script plus a restart. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 +++ bin/make-human-token-keypair.py | 69 +++++++++++++++++++++++++++++++++ docker-compose.yml | 4 ++ 3 files changed, 79 insertions(+) create mode 100755 bin/make-human-token-keypair.py diff --git a/.gitignore b/.gitignore index dad05ec6..4acf2c35 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,9 @@ embeddings/ records/ config.yml data/ + +# Key material. The answer endpoint verifies a proof-of-human token against a +# public key, and whoever mints those tokens holds the private half. Neither +# belongs in the repository. +*.pem +*.key diff --git a/bin/make-human-token-keypair.py b/bin/make-human-token-keypair.py new file mode 100755 index 00000000..2c2d2646 --- /dev/null +++ b/bin/make-human-token-keypair.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Generate the keypair the answer endpoint verifies proof-of-human 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. + +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 +""" + +import stat +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + directory = Path(sys.argv[1]) + directory.mkdir(parents=True, exist_ok=True) + + public_path = directory / "human_token_public.pem" + private_path = directory / "human_token_private.pem" + + # Refuse rather than overwrite: silently replacing a private key would + # invalidate every token in flight with no way back. + for path in (public_path, private_path): + if path.exists(): + print(f"{path} exists. Move it aside first if you mean to rotate.") + return 1 + + private_key = Ed25519PrivateKey.generate() + private_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + private_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 + + public_path.write_bytes( + private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + public_path.chmod(0o644) # The container reads this as a non-root user. + + print(f"public {public_path} (0644, mounted read-only into the container)") + print(f"private {private_path} (0600, for whoever mints tokens -- D1)") + print() + print("Point the service at the public half:") + print(f" HUMAN_TOKEN_PUBLIC_KEY_PATH=/run/secrets/{public_path.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker-compose.yml b/docker-compose.yml index d6844511..0072a20b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET} - CHAINLIT_AUTH_SECRET=${CHAINLIT_AUTH_SECRET} - CHAINLIT_URI=${CHAINLIT_URI} + - HUMAN_TOKEN_PUBLIC_KEY_PATH=${HUMAN_TOKEN_PUBLIC_KEY_PATH} - CHAINLIT_URL=${CHAINLIT_URL} - CHAINLIT_ROOT_PATH=${CHAINLIT_ROOT_PATH} - TAVILY_API_KEY=${TAVILY_API_KEY} @@ -36,6 +37,7 @@ services: - ./embeddings:/app/embeddings - ./records:/app/records - ./config.yml:/app/config.yml + - ./deploy/beta/human_token_public.pem:/run/secrets/human_token_public.pem:ro chainlit-no-login: image: ${CHAINLIT_IMAGE} @@ -52,6 +54,7 @@ services: - CLOUDFLARE_SECRET_KEY=${CLOUDFLARE_SECRET_KEY} - CLOUDFLARE_SITE_KEY=${CLOUDFLARE_SITE_KEY} - CHAINLIT_URI=${CHAINLIT_URI_NO_LOGIN} + - HUMAN_TOKEN_PUBLIC_KEY_PATH=${HUMAN_TOKEN_PUBLIC_KEY_PATH} - CHAINLIT_URI_LOGIN=${CHAINLIT_URI} - CHAINLIT_URL=${CHAINLIT_URL} - TAVILY_API_KEY=${TAVILY_API_KEY} @@ -63,6 +66,7 @@ services: volumes: - ./embeddings:/app/embeddings - ./config.yml:/app/config.yml + - ./deploy/beta/human_token_public.pem:/run/secrets/human_token_public.pem:ro postgres: From f311f659f615ce18a3c7d114c7086cf78a4b9ec1 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Fri, 18 Sep 2026 03:57:04 +0000 Subject: [PATCH 2/2] Fix what the review of this branch found **I edited the wrong compose file.** Both compose.yaml and docker-compose.yml are tracked, and Docker Compose prefers compose.yaml when both exist -- so the key went into the file Compose ignores. compose.yaml also already had a secrets convention, six external secrets matching SECRET_NAMES, which the first version of this branch did not follow. **A missing bind mount makes Docker create a root-owned directory.** Verified: mounting ./deploy/beta/human_token_public.pem when the file is absent leaves a root-owned directory at that path, which then needs sudo to clear and which the key generator would refuse to write over. A compose secret errors instead and touches nothing on the host. Both files now use a secret. **The repo's own tripwire caught the inconsistency**: a test asserts every secret compose declares is one SECRET_NAMES loads, because a secret mounted and never read silently falls back to the environment. This key genuinely is read -- as a file, through HUMAN_TOKEN_PUBLIC_KEY_PATH -- so FILE_SECRET_NAMES records that category rather than the name being added to SECRET_NAMES, which would have claimed the PEM is loaded into the environment when nothing reads it there. A second test keeps that from becoming an escape hatch: a name listed there must be read as /run/secrets/ somewhere. Both are mutation-checked. **cryptography was only a transitive dependency**, via pyjwt's crypto extra, while the keypair script and the token tests import it directly -- the same shape as the pyjwt lock failure earlier. Declared, lock regenerated, and nothing else moved: the only change is the content hash. Co-Authored-By: Claude Opus 5 --- compose.yaml | 10 ++++++++++ docker-compose.yml | 21 +++++++++++++++++++-- poetry.lock | 2 +- pyproject.toml | 4 ++++ src/util/secrets.py | 8 ++++++++ tests/util/test_secrets.py | 33 +++++++++++++++++++++++++++++++-- 6 files changed, 73 insertions(+), 5 deletions(-) diff --git a/compose.yaml b/compose.yaml index e5852754..4fc671af 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,9 +28,11 @@ services: OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET} OPENAI_API_KEY: ${OPENAI_API_KEY} TAVILY_API_KEY: ${TAVILY_API_KEY} + HUMAN_TOKEN_PUBLIC_KEY_PATH: /run/secrets/HUMAN_TOKEN_PUBLIC_KEY # Postgres access when not using Vault (for development) POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} secrets: + - HUMAN_TOKEN_PUBLIC_KEY - CHAINLIT_AUTH_SECRET - CLOUDFLARE_SECRET_KEY - OAUTH_AUTH0_CLIENT_SECRET @@ -70,9 +72,11 @@ services: CLOUDFLARE_SECRET_KEY: ${CLOUDFLARE_SECRET_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY} TAVILY_API_KEY: ${TAVILY_API_KEY} + HUMAN_TOKEN_PUBLIC_KEY_PATH: /run/secrets/HUMAN_TOKEN_PUBLIC_KEY # Postgres access when not using Vault (for development) POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} secrets: + - HUMAN_TOKEN_PUBLIC_KEY - CLOUDFLARE_SECRET_KEY - OPENAI_API_KEY - TAVILY_API_KEY @@ -152,6 +156,12 @@ services: 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 HUMAN_TOKEN_PUBLIC_KEY deploy/beta/human_token_public.pem + # Generate the pair with ./bin/make-human-token-keypair.py deploy/beta + HUMAN_TOKEN_PUBLIC_KEY: + external: true CHAINLIT_AUTH_SECRET: external: true CLOUDFLARE_SECRET_KEY: diff --git a/docker-compose.yml b/docker-compose.yml index 0072a20b..1cee603f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,8 @@ services: - ./embeddings:/app/embeddings - ./records:/app/records - ./config.yml:/app/config.yml - - ./deploy/beta/human_token_public.pem:/run/secrets/human_token_public.pem:ro + secrets: + - human_token_public.pem chainlit-no-login: image: ${CHAINLIT_IMAGE} @@ -66,7 +67,8 @@ services: volumes: - ./embeddings:/app/embeddings - ./config.yml:/app/config.yml - - ./deploy/beta/human_token_public.pem:/run/secrets/human_token_public.pem:ro + secrets: + - human_token_public.pem postgres: @@ -98,3 +100,18 @@ services: volumes: postgres_data: + +secrets: + # NOTE: compose.yaml is the file Docker Compose actually uses when both + # exist, and it declares this as an external secret like every other. + # This file-based form keeps THIS compose file working standalone. + # + # Delivered at /run/secrets/human_token_public.pem, which is what + # HUMAN_TOKEN_PUBLIC_KEY_PATH points at. A secret rather than a bind mount on + # purpose: a bind mount whose source is missing makes Docker create a + # 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 + human_token_public.pem: + file: ./deploy/beta/human_token_public.pem diff --git a/poetry.lock b/poetry.lock index 836a677b..4e4f24cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -9176,4 +9176,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.12, <4" -content-hash = "da54dc988ec5fe6226c1df227eb05770d527401b945fa958310b2d2093f91f55" +content-hash = "f78f40deab1059d18ed2483bb95c5bc4b61369f9f79534419de4d7a293f7a386" diff --git a/pyproject.toml b/pyproject.toml index 86552227..a57dff4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,10 @@ langchain = "^1.4.0" # 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, +# 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" # Declared, not inherited. LangChain 1.0 moved the pre-LCEL chain builders here # (retrievers/rag_chain.py, the metadata_info modules), so this repository # imports langchain_classic directly and must depend on it directly. It arrived diff --git a/src/util/secrets.py b/src/util/secrets.py index 11ab66c3..49d196b5 100644 --- a/src/util/secrets.py +++ b/src/util/secrets.py @@ -127,6 +127,14 @@ def load_secrets_to_environ(names: Iterable[str]) -> None: # # That had already happened: compose.yaml declared the two OAuth secrets and # this tuple did not list them. +# Secrets the app consumes as a FILE at /run/secrets/, not by loading the +# value into the environment. The answer endpoint's verifying key is one: it is +# read through HUMAN_TOKEN_PUBLIC_KEY_PATH, and putting PEM text in an +# environment variable would buy nothing. Listed so the compose tripwire can tell +# "read as a file" apart from "mounted and silently never read", which is the +# drift it exists to catch. +FILE_SECRET_NAMES = ("HUMAN_TOKEN_PUBLIC_KEY",) + SECRET_NAMES = ( "CHAINLIT_AUTH_SECRET", "CLOUDFLARE_SECRET_KEY", diff --git a/tests/util/test_secrets.py b/tests/util/test_secrets.py index 30ee9a18..0f855d95 100644 --- a/tests/util/test_secrets.py +++ b/tests/util/test_secrets.py @@ -6,6 +6,7 @@ import util.secrets as secrets from util.secrets import ( + FILE_SECRET_NAMES, SECRET_NAMES, get_secret, load_secrets_to_environ, @@ -140,10 +141,13 @@ def test_every_secret_compose_declares_is_one_the_app_loads() -> None: (Path(__file__).parent.parent.parent / "compose.yaml").read_text() ) declared = set(compose.get("secrets") or {}) - missing = sorted(declared - set(SECRET_NAMES)) + # FILE_SECRET_NAMES are read from /run/secrets/ directly rather than + # loaded into the environment, so they are accounted for, not unread. + missing = sorted(declared - set(SECRET_NAMES) - set(FILE_SECRET_NAMES)) assert not missing, ( f"compose.yaml mounts these but nothing loads them: {missing}. " - "Add them to SECRET_NAMES in src/util/secrets.py." + "Add them to SECRET_NAMES in src/util/secrets.py, or to " + "FILE_SECRET_NAMES if the app reads the file instead of the value." ) @@ -229,3 +233,28 @@ def test_no_password_anywhere_returns_none( monkeypatch.setattr(secrets, "VAULT_TOKEN_FILE", Path("/nonexistent")) monkeypatch.delenv("POSTGRES_PASSWORD", raising=False) assert secrets.get_db_uri("chainlit") is None + + +def test_file_secrets_are_actually_read_as_files() -> None: + """FILE_SECRET_NAMES excuses a secret from the tripwire above, so it must not + become a way to silence it. + + A name listed there has to be consumed somewhere as a path under + /run/secrets/. If it is not, it is exactly the drift the tripwire exists to + catch, wearing a label that says otherwise. + """ + root = Path(__file__).parent.parent.parent + haystack = "\n".join( + path.read_text() + for path in ( + root / "compose.yaml", + root / "docker-compose.yml", + *(root / "src").rglob("*.py"), + *(root / "bin").glob("*.py"), + ) + ) + for name in FILE_SECRET_NAMES: + assert f"/run/secrets/{name}" in haystack, ( + f"{name} is in FILE_SECRET_NAMES but nothing reads " + f"/run/secrets/{name}. Either wire it up or drop it from the list." + )