Skip to content

Provision the verifying key, so deploying the endpoint cannot take /chat down - #239

Merged
adamjohnwright merged 2 commits into
mainfrom
deploy/human-token-key
Sep 18, 2026
Merged

adamjohnwright merged 2 commits into
mainfrom
deploy/human-token-key

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Deploying current main to beta would have stopped the working chat. This fixes that before it happens.

The problem

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. But Chainlit is mounted on the same FastAPI app, so the blast radius is /chat, not just the new endpoint.

And the key was configured nowhere: not in docker-compose.yml, not in deploy/beta/.env.beta, not in SECRET_NAMES.

Verified against the published image for main (a6fa184)

Run on a spare port, beta untouched throughout:

result
key mounted, valid token answered, 12 citations, first token 14.0s, 0 anchors
key mounted, no token refused in 0.0s
key mounted, expired token refused in 0.0s
key absent exits 3: Cannot read the verifying key at /run/secrets/human_token_public.pem

That last row is the one that matters: it is /chat going down.

What changed

  • ~/update-beta-chat.sh 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 instead of leaving a stopped service.
  • bin/make-human-token-keypair.py generates the pair. It refuses to overwrite an existing key, since silently replacing one would invalidate every token in flight with no way back. Rotation is this script plus a restart.
  • docker-compose.yml — both services that run the app image get the env var and the mount.
  • .gitignore — *.pem and *.key, added before any key existed, so neither half can be committed.

The public key is 0644 because the image runs as appuser:appgroup and must read it; checked in a throwaway container rather than assumed.

Still open

The private half belongs to whoever mints tokens, which is D1 and undecided. What exists now is a beta key that lets the service run and be tested end to end; when D1 lands, regenerate and hand the private half to the minter.

🤖 Generated with Claude Code

adamjohnwright and others added 2 commits September 18, 2026 03:45
…hat 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 <noreply@anthropic.com>
**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/<name> 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 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit cf42b85 into main Sep 18, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the deploy/human-token-key branch September 18, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant