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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -119,7 +121,8 @@ flat PEM files under `certs/<cn>/`.

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`
Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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" }`) |
Expand Down
8 changes: 0 additions & 8 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions ssltui/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</style>
Expand Down Expand Up @@ -572,6 +574,7 @@ def _safe_version(root: Path) -> int:
<option value="powershell">PowerShell</option>
</select>
</div>
<div id="ad-warn"></div>
<div id="ad-preview"></div>
<div id="admodal-btns">
<button class="km-btn" id="ad-tok-btn" onclick="toggleDesignerTok()">Show token</button>
Expand All @@ -585,6 +588,9 @@ def _safe_version(root: Path) -> int:
<script>
const ESC = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const _tok = {{ server_token | tojson }};
// CA name-suffix policy ("" = unrestricted). Mirrors config.name_matches_suffix
// so the API Designer can flag a CN/SAN the server would reject (IPs exempt).
const _suffix = {{ server_suffix | tojson }};

// Event messages that change the cert list (see _format_event server-side):
// "issued (api): cn", "renewed (cron): cn", "revoked (tui): cn", and the
Expand Down Expand Up @@ -1025,8 +1031,53 @@ def _safe_version(root: Path) -> int:
return '';
}

// Names the current endpoint would submit that the CA's suffix policy rejects.
// Mirrors the server: validates the CN and DNS SANs, exempts IP SANs (explicit
// "IP:" prefix or a bare IPv4) and strips a "DNS:" prefix before matching.
function _adNameViolations() {
if (!_suffix) return [];
const host = document.getElementById('ad-inputs');
const ep = _adEp();
const raw = [];
ep.params.forEach(p => {
if (p !== 'cn') return;
const el = host.querySelector('[data-param="cn"]');
if (el && el.value.trim()) raw.push(el.value.trim());
});
if (ep.body) {
ep.body.forEach(f => {
const el = host.querySelector('[data-body="' + f.name + '"]');
if (!el || !el.value.trim()) return;
if (f.cn) raw.push(el.value.trim());
else if (f.type === 'list') {
el.value.split(',').map(s => s.trim()).filter(Boolean).forEach(s => raw.push(s));
}
});
}
const bad = [];
raw.forEach(name => {
if (/^IP:/i.test(name)) return; // explicit IP SAN — exempt
let n = /^DNS:/i.test(name) ? name.slice(4) : name;
if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(n)) return; // bare IPv4 — exempt
const low = n.toLowerCase();
if (low === _suffix || low.endsWith('.' + _suffix)) return;
bad.push(n);
});
return bad;
}

function _adUpdate() {
document.getElementById('ad-preview').textContent = _adGen(_adTokVis);
const warn = document.getElementById('ad-warn');
const bad = _adNameViolations();
if (bad.length) {
warn.textContent = '⚠ CA policy: name(s) must be under .' + _suffix
+ ' — the server will reject: ' + bad.join(', ');
warn.style.display = 'block';
} else {
warn.textContent = '';
warn.style.display = 'none';
}
}

function openDesigner() {
Expand Down Expand Up @@ -1495,12 +1546,15 @@ def dashboard():
ca_nfo = f"{ca_subject(root)} \u00b7 expires {ca_expiry(root)}"
except Exception:
ca_nfo = "CA info unavailable"
suffix = store.get_name_suffix(root)
ca_nfo += f" \u00b7 names .{suffix}" if suffix else " \u00b7 names any"
return render_template_string(
_DASHBOARD_HTML,
ca_info=ca_nfo,
server_url=f"{request.scheme}://{request.host}",
ca_root=str(root),
server_token=token,
server_suffix=suffix or "",
)

@app.get("/dashboard/api/certs")
Expand Down
38 changes: 38 additions & 0 deletions ssltui/ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def init_ca(
key_type: str = "ec",
subject: str | None = None,
server_fqdn: str | None = None,
name_suffix: str | None = None,
) -> None:
"""Create CA key and self-signed certificate in *root*.

Expand All @@ -64,7 +65,15 @@ def init_ca(
server_fqdn: Optional FQDN for the dashboard/API server. When given,
a leaf certificate is issued for it and recorded as the server
cert so ``ssltui serve`` can present HTTPS by default.
name_suffix: Optional CN/SAN name-suffix policy (e.g. ``".local"``).
When set, every later cert request must use names under this
suffix. Blank/None leaves issuance unrestricted.
"""
try:
name_suffix = config.normalize_name_suffix(name_suffix or "")
except ValueError as exc:
raise CAError(str(exc)) from exc

root.mkdir(mode=0o700, parents=True, exist_ok=True)
(root / "certs").mkdir(mode=0o700, exist_ok=True)

Expand Down Expand Up @@ -141,6 +150,13 @@ def init_ca(
token_path.write_text(secrets.token_hex(32))
token_path.chmod(0o600)

# Persist the name-suffix policy before issuing anything so the default
# server cert below is validated against it too.
if name_suffix:
from ssltui.store import set_name_suffix

set_name_suffix(root, name_suffix)

# --- Default dashboard/API server certificate ---
if server_fqdn:
server_fqdn = server_fqdn.strip()
Expand Down Expand Up @@ -185,6 +201,7 @@ def issue_cert(
validity_days = min(validity_days, config.LEAF_VALIDITY_MAX)

san_list = _build_san_list(cn, sans)
_enforce_name_suffix(root, cn, san_list)
san_ext = ",".join(san_list)

cert_dir = config.cert_dir(root, cn)
Expand Down Expand Up @@ -511,6 +528,27 @@ def local_ip_sans() -> list[str]:
return out


def _enforce_name_suffix(root: Path, cn: str, san_list: list[str]) -> None:
"""Reject the request if the CN or any DNS SAN escapes the CA's policy.

IP SANs are exempt — a name-suffix policy only constrains DNS hostnames.
No-op when the CA was initialised without a suffix restriction.
"""
from ssltui.store import get_name_suffix

suffix = get_name_suffix(root)
if not suffix:
return

names = [cn] + [e[4:] for e in san_list if e.startswith("DNS:")]
offenders = [n for n in names if not config.name_matches_suffix(n, suffix)]
if offenders:
uniq = ", ".join(dict.fromkeys(offenders))
raise CAError(
f"name(s) not permitted by CA policy (must be under .{suffix}): {uniq}"
)
Comment on lines +531 to +549


def _build_san_list(cn: str, extra_sans: list[str] | None) -> list[str]:
"""Build the full SAN list, always including the CN."""
sans: list[str] = []
Expand Down
36 changes: 36 additions & 0 deletions ssltui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,39 @@ def validate_cn(cn: str) -> str:
def _safe_cn(cn: str) -> str:
"""Map a CN to a safe filesystem name."""
return cn.replace("*", "wildcard").replace("/", "_")


# A name-suffix policy is a plain dotted DNS label sequence (no wildcard, no
# leading dot) — e.g. "local" or "dev.corp". The empty string means no policy.
_SUFFIX_RE = re.compile(r"^([A-Za-z0-9_-]+\.)*[A-Za-z0-9_-]+$")


def normalize_name_suffix(raw: str) -> str:
"""Normalise a CA name-suffix policy string.

Strips surrounding whitespace and a single leading dot and lowercases, so
``.local`` and ``local`` are stored identically. Returns ``""`` when the
value is blank (the user bypassed the restriction). Raises ``ValueError``
when a non-empty value isn't a plain dotted DNS label sequence.
"""
s = raw.strip()
if s.startswith("."):
s = s[1:].strip()
s = s.lower()
if not s:
return ""
if len(s) > 253 or not _SUFFIX_RE.match(s):
raise ValueError(f"Invalid name suffix: {raw!r}")
return s


def name_matches_suffix(name: str, suffix: str) -> bool:
"""True if a DNS name satisfies the configured ``suffix`` restriction.

The name must equal the suffix or sit under it (``app.local`` for a
``local`` suffix). Comparison is case-insensitive; ``suffix`` is expected to
already be normalised via :func:`normalize_name_suffix`.
"""
name = name.strip().lower()
suffix = suffix.lower()
return name == suffix or name.endswith("." + suffix)
25 changes: 25 additions & 0 deletions ssltui/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,31 @@ def set_server_fqdn(root: Path, fqdn: str | None) -> None:
_bump_version(conn)


def get_name_suffix(root: Path) -> str | None:
"""Return the CA's required CN/SAN name suffix, or None if unrestricted."""
if not _exists(root):
return None
with _connect(root) as conn:
row = conn.execute(
"SELECT value FROM meta WHERE key = 'name_suffix'"
).fetchone()
return row["value"] if row else None


def set_name_suffix(root: Path, suffix: str | None) -> None:
"""Set (or clear, with a falsy value) the required CN/SAN name suffix."""
with _connect(root) as conn:
if suffix:
conn.execute(
"INSERT INTO meta (key, value) VALUES ('name_suffix', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(suffix,),
)
else:
conn.execute("DELETE FROM meta WHERE key = 'name_suffix'")
_bump_version(conn)


def next_crl_number(root: Path) -> int:
with _connect(root) as conn:
n = _incr(conn, "crl_number")
Expand Down
Loading
Loading