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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ at different directories or by using **p Root dir** in the TUI to switch at runt
~/.local/share/ssltui/
├── ca.key # CA private key (0600)
├── ca.crt # CA certificate (self-signed)
├── index.json # issued cert metadata
├── ca.db # SQLite store: cert index, revocations, event log, counters
├── ca.crl # certificate revocation list (PEM)
├── api_token # generated API/dashboard bearer token (0600)
├── certs/
│ └── <cn>/
│ ├── cert.crt # signed leaf certificate
Expand All @@ -85,6 +87,12 @@ at different directories or by using **p Root dir** in the TUI to switch at runt
└── renewal.log
```

Cert and key material stays as flat PEM files under `certs/<cn>/`; all
metadata — the cert index, revocation list, an append-only event log, and the
serial / CRL-number counters — lives in the single SQLite database `ca.db`.
It runs in WAL mode so the TUI, the cron `renew` job, and the Flask API can
read and write concurrently without explicit file locking.

## Using the TUI

The interactive TUI is the default mode (`uv run ssltui`). Press **i** to
Expand Down Expand Up @@ -239,6 +247,14 @@ If you expose the server beyond localhost, treat that token like a secret. The
API can return private keys, so it should only be reachable from trusted test
infrastructure.

### Input validation

The `<cn>` path parameter is validated against a strict hostname allow-list
(DNS hostnames and wildcards only). Anything else — path separators, `..`, or
control characters — is rejected with `400 Bad Request` before any lookup.
Certificate, key, and chain downloads are confined to the CA's `certs/<cn>/`
directory, so a request can never read files outside it.

### Endpoints

- `GET /api/v1/certs` lists all certificates
Expand Down
42 changes: 28 additions & 14 deletions ssltui/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,13 @@ def _unexpected_error(e: Exception):
# --- Internal helpers ---

def _entry_or_404(cn: str) -> dict:
# Validate against the strict hostname allow-list before the CN is used
# for any lookup or path derivation — an explicit sanitizer for every
# endpoint that takes a <cn> route parameter.
try:
cn = config.validate_cn(cn)
except ValueError as exc:
abort(400, description=str(exc))
entry = store.get_cert(root, cn)
Comment thread
Copilot marked this conversation as resolved.
if entry is None:
abort(404, description=f"No cert found for CN={cn!r}")
Expand Down Expand Up @@ -1243,6 +1250,10 @@ def issue():
cn = (body.get("cn") or "").strip()
if not cn:
abort(400, description="cn is required")
try:
cn = config.validate_cn(cn)
except ValueError as exc:
abort(400, description=str(exc))
if store.get_cert(root, cn) is not None:
abort(
409,
Expand Down Expand Up @@ -1277,21 +1288,26 @@ def renew(cn: str):
abort(400, description=str(exc))
return jsonify(meta) # type: ignore[return-value]

# Downloads re-derive the path from the containment-checked cert_dir rather
# than trusting the stored path string, so a tampered ca.db cannot redirect
# a read outside certs/<cn>/.
@app.get("/api/v1/certs/<cn>/cert.pem")
def download_cert(cn: str):
entry = _entry_or_404(cn)
return _pem_response(Path(entry["cert"]), f"{_safe(cn)}.crt")
_entry_or_404(cn)
return _pem_response(config.cert_dir(root, cn) / "cert.crt", f"{_safe(cn)}.crt")

@app.get("/api/v1/certs/<cn>/key.pem")
def download_key(cn: str):
entry = _entry_or_404(cn)
_entry_or_404(cn)
store.add_event(root, "key_download", cn=cn, method="api")
return _pem_response(Path(entry["key"]), f"{_safe(cn)}.key")
return _pem_response(config.cert_dir(root, cn) / "cert.key", f"{_safe(cn)}.key")

@app.get("/api/v1/certs/<cn>/chain.pem")
def download_chain(cn: str):
entry = _entry_or_404(cn)
return _pem_response(Path(entry["chain"]), f"{_safe(cn)}-chain.pem")
_entry_or_404(cn)
return _pem_response(
config.cert_dir(root, cn) / "chain.crt", f"{_safe(cn)}-chain.pem"
)

# --- Dashboard auth helpers ---

Expand Down Expand Up @@ -1386,18 +1402,16 @@ def _generate():
@app.get("/dashboard/certs/<cn>/cert.pem")
@_require_session_api
def dashboard_cert_download(cn: str):
entry = store.get_cert(root, cn)
if not entry:
abort(404)
return _pem_response(Path(entry["cert"]), f"{_safe(cn)}.crt")
_entry_or_404(cn)
return _pem_response(config.cert_dir(root, cn) / "cert.crt", f"{_safe(cn)}.crt")

@app.get("/dashboard/certs/<cn>/chain.pem")
@_require_session_api
def dashboard_chain_download(cn: str):
entry = store.get_cert(root, cn)
if not entry:
abort(404)
return _pem_response(Path(entry["chain"]), f"{_safe(cn)}-chain.pem")
_entry_or_404(cn)
return _pem_response(
config.cert_dir(root, cn) / "chain.crt", f"{_safe(cn)}-chain.pem"
)

# The root CA cert and CRL are public material (the CA cert is sent in every
# TLS handshake; the CRL is meant to be published). They are served without a
Expand Down
5 changes: 5 additions & 0 deletions ssltui/ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ def issue_cert(
if not config.ca_key_path(root).exists():
raise CAError("CA not initialised. Run init_ca() first.")

try:
cn = config.validate_cn(cn)
except ValueError as exc:
raise CAError(str(exc)) from exc

validity_days = min(validity_days, config.LEAF_VALIDITY_MAX)

san_list = _build_san_list(cn, sans)
Expand Down
30 changes: 29 additions & 1 deletion ssltui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
from pathlib import Path

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -40,7 +41,17 @@ def db_path(root: Path) -> Path:


def cert_dir(root: Path, cn: str) -> Path:
return root / "certs" / _safe_cn(cn)
# Validate against the strict hostname allow-list first: this is the
# primary sanitizer for any CN that reaches a filesystem path, so every
# caller (issue/renew/revoke) is covered regardless of prior validation.
cn = validate_cn(cn)
base = (root / "certs").resolve()
candidate = (base / _safe_cn(cn)).resolve()
Comment thread
hali-coding marked this conversation as resolved.
Dismissed
# Defense in depth: must be exactly one level under certs/ — blocks "..",
# absolute paths, and any other traversal that survives _safe_cn.
if candidate.parent != base:
raise ValueError(f"Invalid certificate common name: {cn!r}")
return candidate


def crl_path(root: Path) -> Path:
Expand Down Expand Up @@ -74,6 +85,23 @@ def renewal_log_path(root: Path) -> Path:
# ---------------------------------------------------------------------------


# CN is a DNS hostname or wildcard label — strict allow-list. No path
# separators, no traversal, no control characters.
_CN_RE = re.compile(r"^(\*\.)?([A-Za-z0-9_-]+\.)*[A-Za-z0-9_-]+$")


def validate_cn(cn: str) -> str:
"""Validate a certificate CN against a strict hostname allow-list.

Returns the stripped CN. Raises ValueError on anything that isn't a plain
DNS hostname or wildcard (rejecting path separators, ``..``, control chars).
"""
cn = cn.strip()
if not cn or len(cn) > 253 or not _CN_RE.match(cn):
raise ValueError(f"Invalid certificate common name: {cn!r}")
return cn
Comment thread
hali-coding marked this conversation as resolved.


def _safe_cn(cn: str) -> str:
"""Map a CN to a safe filesystem name."""
return cn.replace("*", "wildcard").replace("/", "_")
Loading