From 9283ba11b277fab6fee29970627861ba98a7f879 Mon Sep 17 00:00:00 2001 From: hampuslinden <62944155+hampuslinden@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:50:56 +0100 Subject: [PATCH] feat: allow domain suffix to be locked down --- .gitattributes | 1 + .github/workflows/pr.yaml | 2 +- CLAUDE.md | 10 +++-- README.md | 6 +++ docs/API.md | 9 ++++- docs/TODO.md | 8 ---- docs/TUI.md | 22 +++++++++++ ssltui/api.py | 54 ++++++++++++++++++++++++++ ssltui/ca.py | 38 ++++++++++++++++++ ssltui/config.py | 36 +++++++++++++++++ ssltui/store.py | 25 ++++++++++++ ssltui/tui.py | 35 ++++++++++++++--- tests/test_2_store.py | 82 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 308 insertions(+), 20 deletions(-) diff --git a/.gitattributes b/.gitattributes index 2d1846a..fb7c1a4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +* text=auto *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text *.ico filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 804b7ec..1140d00 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -40,7 +40,7 @@ jobs: which tmux > /dev/null || sudo apt install -y tmux mkdir -p test-results echo "::startgroup::Running tests" - uv run pytest -v \ + uv run --all-extras pytest -v \ --junitxml=test-results/junit.xml \ | tee test-results/pytest.log echo "::endgroup::Running tests" diff --git a/CLAUDE.md b/CLAUDE.md index 26d4d4a..1405682 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ A simple Flask API and dashboard mode is also available for issuing and download | TUI | `textual` | | Cryptography / cert generation | `ssl`, `subprocess` → `openssl` CLI | | Scheduling / renewal | `cron` entry + headless CLI mode | -| Config persistence | `json` (stdlib) | +| Config persistence | `sqlite` (stdlib) | | Subprocess management | `subprocess` (stdlib) | | Linting | `ruff` | @@ -60,6 +60,8 @@ Use only post-2020 cipher suites. Minimum TLS 1.2, prefer TLS 1.3. - Allow the user to create the root key with a passphrase but warn them they have to enter this every single time they use the CA. The CA is not intended for production use, and the user should be aware of the security implications of using a local CA. Default should be blank. +- At init, allow the user to optionally restrict the CN/SAN name suffix (e.g. `.local`). The input is presented but can be bypassed (blank = unrestricted). The policy is stored in the `meta` table (`name_suffix`, normalised by `config.normalize_name_suffix`) and enforced in `ca.issue_cert` — the single chokepoint all modes funnel through — against the CN and every DNS SAN (IP SANs exempt). It is fixed at init time. + ## Application Modes ### Interactive TUI mode (default) @@ -119,7 +121,8 @@ flat PEM files under `certs//`. Tables: `certs` (CN → metadata JSON), `revoked` (serial → JSON), `events` (`id`, `ts`, `type`, `cn`, `method`, `detail`), and `meta` (serial, crl_number, -server_fqdn, and a `version` counter the dashboard/TUI watchers poll to refresh). +server_fqdn, an optional `name_suffix` CN/SAN policy, and a `version` counter +the dashboard/TUI watchers poll to refresh). Events record lifecycle actions (`issue`, `renew`, `revoke`) and every private **key access** (`key_download`), each tagged with the originating `method` @@ -165,5 +168,4 @@ Renewal threshold: renew if cert expires within 30 days (configurable). The `ren - The CLAUDE.md file should include the technical overview, architecture, and design decisions. -- The API.md file should include the API endpoints, request/response formats, and authentication details. Including examples. - +- Further documentation in the `docs/` directory. diff --git a/README.md b/README.md index 712151f..ef33ac6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ uv run ssltui # launch the TUI On first launch the CA is uninitialised. Press **i** or click **Init CA** to create a root CA, then **n** to issue your first certificate. +When initialising the CA you can optionally **restrict the CN / SAN suffix** +(e.g. `.local`). Once set, every certificate the CA issues — via the TUI, CLI, +API, or cron renewal — must use names under that suffix; the CN and all DNS SANs +are validated at issue time (IP SANs are exempt). Leave the field blank to allow +any name. See [TUI.md](docs/TUI.md) for details. + ## Data directory Certificates and keys are stored in `~/.local/share/ssltui/` by default. diff --git a/docs/API.md b/docs/API.md index 0f5119d..8081d45 100644 --- a/docs/API.md +++ b/docs/API.md @@ -146,6 +146,13 @@ Invalid input (missing `cn`, bad `key_type`, etc.) returns `400`: { "error": "cn is required" } ``` +If the CA was initialised with a name-suffix restriction, a CN or DNS SAN +outside that suffix is also rejected with `400` (IP SANs are exempt): + +```json +{ "error": "name(s) not permitted by CA policy (must be under .local): app.dev" } +``` + ### Get certificate metadata ``` @@ -239,7 +246,7 @@ HTTP status code: | Status | Meaning | |--------|---------| -| `400 Bad Request` | Invalid or missing request fields (e.g. no `cn`, bad `key_type`) | +| `400 Bad Request` | Invalid or missing request fields (e.g. no `cn`, bad `key_type`), or a CN/SAN outside the CA's name-suffix policy | | `401 Unauthorized` | Missing or incorrect bearer token | | `404 Not Found` | No certificate exists for the given CN, or the file is missing | | `500 Internal Server Error` | Unexpected server error (`{ "error": "Internal server error" }`) | diff --git a/docs/TODO.md b/docs/TODO.md index a9835e4..8f9df11 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -30,11 +30,3 @@ Goal: `ssltui` behaves like a first-class system tool, not just a Python entry p - [ ] Provide a man page (`man ssltui`) and shell completions (bash/zsh/fish). - [ ] Optional `systemd` user units for the API/dashboard serve mode and a timer-based alternative to the cron renewal entry. - -## Test coverage - -## Improve test harness - -- [ ] Add a test harness for the CLI -- [ ] Add a test harness for the TUI -- [ ] Add a web test harness for the API \ No newline at end of file diff --git a/docs/TUI.md b/docs/TUI.md index 6e8a162..745947a 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -9,6 +9,24 @@ uv run ssltui On first launch the CA is uninitialised. Press **i** or click **Init CA** to create a root CA, then **n** to issue your first certificate. +## Initialising the CA + +Press **i** to open the Init CA form: + +- **CA Subject (DN)** — the root certificate's distinguished name. +- **Dashboard / API server FQDN** — optional; issues a default HTTPS cert for + this host so `ssltui serve` advertises HTTPS. +- **Restrict CN / SAN suffix** — optional name-suffix policy. Enter a suffix + such as `.local` to lock every certificate this CA issues to names under that + suffix; the CN and all DNS SANs are validated against it at issue time + (`app.local` and `*.app.local` pass, `app.dev` is rejected). IP SANs are + exempt. **Leave it blank to bypass the restriction and allow any name.** The + policy is fixed at init and applies to every mode (TUI, CLI, API, cron renew). +- **Key type** — EC P-384 (default) or RSA 4096. + +If you also supply a server FQDN, it must satisfy the suffix too, otherwise init +fails with a clear error. + ## Issuing a certificate Press **n** to open the issue form: @@ -28,6 +46,10 @@ www.myapp.local, api.myapp.local, 192.168.1.10 Wildcard certs (`*.myapp.local`) automatically include the base domain (`myapp.local`) as a second SAN. +If the CA was initialised with a name-suffix restriction, the issue form shows +the active policy (e.g. *names must be under `.local`*) and rejects a CN or DNS +SAN that falls outside it. + ## Keyboard reference ### Main screen diff --git a/ssltui/api.py b/ssltui/api.py index 02af9dc..8829885 100644 --- a/ssltui/api.py +++ b/ssltui/api.py @@ -430,6 +430,8 @@ def _safe_version(root: Path) -> int: overflow:auto;font-family:var(--fnt);line-height:1.7;margin-bottom:12px} #admodal-btns{display:flex;gap:8px;align-items:center;flex-shrink:0} #ad-copy-st{font-size:11px;color:var(--grn);margin-left:auto} +#ad-warn{display:none;color:var(--red);font-size:12px;line-height:1.5; + border:1px solid var(--red);padding:6px 10px;margin-bottom:10px;flex-shrink:0} .ad-fmt-row{display:flex;gap:10px;align-items:center;margin-bottom:8px} .ad-fmt-row label{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px} @@ -572,6 +574,7 @@ def _safe_version(root: Path) -> int: +
@@ -585,6 +588,9 @@ def _safe_version(root: Path) -> int: