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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
69 changes: 69 additions & 0 deletions bin/make-human-token-keypair.py
Original file line number Diff line number Diff line change
@@ -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())
10 changes: 10 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -36,6 +37,8 @@ services:
- ./embeddings:/app/embeddings
- ./records:/app/records
- ./config.yml:/app/config.yml
secrets:
- human_token_public.pem

chainlit-no-login:
image: ${CHAINLIT_IMAGE}
Expand All @@ -52,6 +55,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}
Expand All @@ -63,6 +67,8 @@ services:
volumes:
- ./embeddings:/app/embeddings
- ./config.yml:/app/config.yml
secrets:
- human_token_public.pem


postgres:
Expand Down Expand Up @@ -94,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
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/util/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>, 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",
Expand Down
33 changes: 31 additions & 2 deletions tests/util/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import util.secrets as secrets
from util.secrets import (
FILE_SECRET_NAMES,
SECRET_NAMES,
get_secret,
load_secrets_to_environ,
Expand Down Expand Up @@ -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/<name> 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."
)


Expand Down Expand Up @@ -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."
)
Loading