diff --git a/.gitignore b/.gitignore index dad05ec..4acf2c3 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 0000000..2c2d264 --- /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/compose.yaml b/compose.yaml index e585275..4fc671a 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 d684451..1cee603 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,8 @@ services: - ./embeddings:/app/embeddings - ./records:/app/records - ./config.yml:/app/config.yml + secrets: + - human_token_public.pem chainlit-no-login: image: ${CHAINLIT_IMAGE} @@ -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} @@ -63,6 +67,8 @@ services: volumes: - ./embeddings:/app/embeddings - ./config.yml:/app/config.yml + secrets: + - human_token_public.pem postgres: @@ -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 diff --git a/poetry.lock b/poetry.lock index 836a677..4e4f24c 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 8655222..a57dff4 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 11ab66c..49d196b 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 30ee9a1..0f855d9 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." + )