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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
]

Expand Down
15 changes: 15 additions & 0 deletions tests/unit/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading