diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ff9c4..44c0604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). E ## [Unreleased] +### Fixed + +- A passphrase-protected `TLS_KEY_FILE` now stops startup with a message that the key must be + unencrypted. Before, the app could wait at a password prompt on a host with a terminal. + ## [0.7.2] - 2026-10-27 ### Added diff --git a/INSTALL.md b/INSTALL.md index 9de0d7a..42937a8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -234,11 +234,11 @@ The API serves HTTPS itself when `TLS_CERT_FILE` and `TLS_KEY_FILE` both point a chmod 644 tls/cert.pem && chmod 640 tls/key.pem && sudo chgrp 44 tls/key.pem ``` - The app runs as user 1000, group 44 (`video`) in the container, so the key must be readable by one of them. + The app runs as user 1000, group 44 (`video`) in the container, so the key must be readable by one of them. The key must be unencrypted (`-nodes` above): the app does not ask for a passphrase. 1. Uncomment the `./tls:/app/tls:ro` volume under `app` in `compose.yml`. -1. Set the files and the HTTPS base URL in `.env`, then run `docker compose up -d`: +1. Set the files and the HTTPS base URL in the `.env` next to `compose.yml` (Compose passes it to the container, so the healthcheck sees it too), then run `docker compose up -d`: ```sh TLS_CERT_FILE=/app/tls/cert.pem diff --git a/app/core/settings.py b/app/core/settings.py index 2cdf2df..b5df1ad 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -120,9 +120,10 @@ def validate_tls_files(cert_file: str, key_file: str) -> None: if not cert_file: return try: - ssl.create_default_context(ssl.Purpose.CLIENT_AUTH).load_cert_chain(cert_file, key_file) + # An empty password makes an encrypted key fail here instead of OpenSSL prompting for one. + ssl.create_default_context(ssl.Purpose.CLIENT_AUTH).load_cert_chain(cert_file, key_file, password=b"") except (OSError, ssl.SSLError) as exc: - msg = f"TLS_CERT_FILE and TLS_KEY_FILE could not be loaded: {exc}" + msg = f"TLS_CERT_FILE and TLS_KEY_FILE could not be loaded (the key must be unencrypted): {exc}" raise ValueError(msg) from exc diff --git a/pyproject.toml b/pyproject.toml index 26bc176..5073a93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ # Dependencies and version constraints dependencies = [ "fastapi>=0.115.8", - "fastapi-cli>=0.0.32", # NOTE: the Dockerfile CMD and `just dev` use `fastapi run|dev` + "fastapi-cli>=0.0.32", # NOTE: `just dev` runs `fastapi dev`; the image runs app/serve.py "httpx>=0.28,<1", "jinja2>=3.1.5", # NOTE: Jinja2Templates "opentelemetry-exporter-otlp-proto-http>=1.38.0", @@ -40,7 +40,7 @@ "pyjwt[crypto]>=2.12.1", "python-multipart>=0.0.18", # NOTE: Form() fields in app/auth/router.py "r9cam-models>=0.6.1,<0.7", # NOTE: Keep this aligned with the published contract package - "uvicorn[standard]>=0.12.0", # NOTE: the server behind `fastapi run`; [standard] adds uvloop and httptools + "uvicorn[standard]>=0.12.0", # NOTE: app/serve.py runs it; [standard] adds uvloop and httptools "websockets>=16.0", ] diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index f0eea1c..6c2b340 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from cryptography.hazmat.primitives import serialization from pydantic import HttpUrl import app.core.bootstrap as config_mod @@ -361,6 +362,20 @@ def test_key_that_does_not_match_is_rejected(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="could not be loaded"): Settings(tls_cert_file=str(cert), tls_key_file=str(other_key)) + def test_encrypted_key_fails_instead_of_prompting(self, tmp_path: Path) -> None: + """A passphrase-protected key stops startup at once; OpenSSL must not wait for a password.""" + cert, key = write_self_signed_cert(tmp_path) + loaded = serialization.load_pem_private_key(key.read_bytes(), password=None) + key.write_bytes( + loaded.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"secret"), + ) + ) + with pytest.raises(ValueError, match="unencrypted"): + Settings(tls_cert_file=str(cert), tls_key_file=str(key)) + def test_valid_pair_turns_tls_on(self, tmp_path: Path) -> None: """A readable certificate and matching key enable TLS.""" cert, key = write_self_signed_cert(tmp_path)