Add design doc for configurable certificate lifetime

CERT_DAYS setting, --days CLI flag, and a Days field in the TUI cert
form. "default"/"" inherit EasyRSA's own EASYRSA_CERT_EXPIRE so that
deploying this cannot silently shorten certs on an existing PKI.

Records why --days=N beats exporting EASYRSA_CERT_EXPIRE, and why the
local rejection of 0 is a fast-fail mirror of EasyRSA's own gate rather
than a substitute for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-08-15 04:51:59 +03:00
parent c2f51a0920
commit 506787f8fc

View File

@@ -0,0 +1,122 @@
# Configurable Certificate Lifetime — Design
**Goal:** Let the operator choose how long an issued certificate is valid — as a setting (`CERT_DAYS`), a CLI flag (`--days`), and a field in the TUI cert form — while leaving today's behaviour untouched for anyone who doesn't set it.
**Status:** approved 2026-08-15, ready for an implementation plan.
## Background
`build_client_full()` passes nothing about validity to EasyRSA today, so every certificate inherits `EASYRSA_CERT_EXPIRE` from the `vars` file (365 days on the deployment that prompted this). There is no way to issue a shorter-lived cert without editing `vars`, which changes it for every tool that touches the PKI.
## Decisions
### The setting
```python
CERT_DAYS = "default" # lifetime of certs this tool issues, in days.
# "default" or "" = leave it to EasyRSA (your vars'
# EASYRSA_CERT_EXPIRE). A positive integer overrides
# it — e.g. 90.
```
`"default"` and `""` both mean *inherit*. This is deliberate: deploying the new version must not silently shorten certificates on an existing PKI. Anyone who wants a fixed lifetime sets a number.
`CERT_DAYS` joins `_OVERRIDABLE_SETTINGS`, so an external `.conf` can set it like any other setting. The repo ships no sample `.conf` (`CONFIG_PATH` defaults to `<script>.conf` next to the script), so "in the external config" means *overridable and documented*, not a new file.
### `resolve_cert_days()`
Lives in the EASYRSA section beside `resolve_ca_passphrase()` and follows the same sentinel-resolution shape. Called once in `main()` after argument parsing.
| Input | Result |
|---|---|
| `"default"`, `""` | `""` — omit the flag, EasyRSA decides |
| `90`, `"90"` | `"90"` |
| `"090"` | `"90"` — normalised, see below |
| `0`, `"0"` | `ConfigError` |
| `-5`, `"abc"`, `"9 0"` | `ConfigError` |
It accepts both `int` and `str` because people write either in a `.conf`.
Two behaviours need justifying:
**Rejecting `0` is a fast-fail mirror of EasyRSA's own rule, not a substitute for it.** EasyRSA already refuses: `--days=0` passes option parsing (`zero_allowed=1`, `easyrsa:6955`) and then hits an explicit gate at `easyrsa:5701``Cannot use --days=0 for command build-client-full`. Checking locally only moves the failure somewhere cheaper: in the TUI, to the still-open form instead of after it closes; on the CLI, to before the CA passphrase prompt and a subprocess round-trip. The message says why:
```
CERT_DAYS must be a positive number of days (EasyRSA rejects 0); got "0"
```
**Normalising leading zeros avoids a baffling error.** EasyRSA's validation pattern is `*[!1234567890]*|0*` (`easyrsa:7178`), so `--days=090` fails with `Number expected: '090'` — an unhelpful thing to read when you typed nothing but digits. `int()` then `str()` sidesteps it.
### Passing it to EasyRSA
Use the **`--days=N` global option**, appended in `build_client_full()`'s own argument list:
```python
_base_cmd(...) + [f"--passout=pass:{key_passphrase}"]
+ ([f"--days={days}"] if days else [])
+ ["build-client-full", cn]
```
Rejected alternative: exporting `EASYRSA_CERT_EXPIRE` through `extra_env`, which `build_client_full()` already uses for the email. `set_var` is `export X="${X-$default}"` (`easyrsa:6170`), so it does respect an existing environment value — but a `vars` file that uses a plain `EASYRSA_CERT_EXPIRE=365` assignment instead of `set_var` overwrites it silently. `--days` has no such failure mode: EasyRSA exports it at command dispatch, after `vars` is sourced. It is also the guarded path — `EASYRSA_CERT_EXPIRE=0` reaches `openssl ca -days 0` with no gate (`easyrsa:2738`), while `--days=0` is refused.
**The flag must not go in `_base_cmd()`.** That helper is shared with `gen-crl`, where `--days` sets CRL validity instead (`easyrsa:7270`).
`_run_easyrsa()`'s verb detection (`next(c for c in cmd[1:] if not c.startswith("-"))`) is unaffected: `--days=90` starts with `-`, so `build-client-full` is still found.
### CLI
`--days N`, a non-exclusive argument alongside `--email`. Overrides `CERT_DAYS` for that run of `--create` or `--reissue`. Parsed through `resolve_cert_days()` so a bad value reads the same however it arrives.
Like `--email`, it is accepted-but-unused with `--revoke`, `--gen-crl` and `--list*` rather than being an error — consistent with the existing parser, which does not police flag combinations beyond its one mutually-exclusive group.
`CliRunner.create()` and `.reissue()` gain a `days: str = ""` parameter, forwarded to `_issue()` and on to `build_client_full()`.
### TUI
The cert form grows a fourth field, in order **CN, Email, Days, Password**. Fields move to y=3,5,7,9; buttons to y=11; the window grows from 13 to 15 rows, which still fits an 80x24 terminal. `CertFormResult` gains `days: str`.
The Days field is **digits-only**: `InputField` gains an optional `allowed: Optional[str] = None` charset that filters insertions in `handle_key()` — pure logic, directly testable, no curses needed. Empty means inherit, so `"default"` never has to be typed.
That leaves `0` (and `00`, …) as the only reachable invalid input. Submitting it shows the `resolve_cert_days()` message on the hint line in `COLOR_ERROR` and keeps the form open, rather than closing the form and failing later.
The field is seeded from the resolved `CERT_DAYS`: blank when inheriting, otherwise the number.
## Data flow
```
CERT_DAYS (SETTINGS) ──┐
.conf override ──┤
├─► resolve_cert_days() ─► "" | "90"
--days N (CLI) ──┘ │
├─► CliRunner._issue()
TUI form "Days" field ─► resolve_cert_days() ──────────┤
└─► build_client_full(days=…)
--days=90 ──┘ (omitted when "")
```
## Error handling
| Failure | Where it surfaces |
|---|---|
| Bad `CERT_DAYS` in `.conf` or SETTINGS | `ConfigError` in `main()`, printed and exit 1 — same path as other config errors |
| Bad `--days` on the CLI | `ConfigError` at parse time, printed and exit 1 |
| `0` typed in the TUI form | Hint line in `COLOR_ERROR`, form stays open |
| Anything EasyRSA still rejects | `EasyRSAError` with EasyRSA's own text, now legible via `_easyrsa_diagnostics()` |
## Testing
- `resolve_cert_days()` table: valid, both sentinels, `0`, negative, junk, `int` vs `str`, leading zeros.
- `build_client_full()` includes `--days=90` when given, omits it when `""`, and keeps `--days` out of `_base_cmd()` (assert `revoke_issued`/`gen_crl` commands carry no `--days`).
- `--days` parses and reaches `create`/`reissue`; a bad value exits 1.
- `CERT_DAYS` survives an external `.conf` override (extend the existing `_OVERRIDABLE_SETTINGS` test).
- `InputField` drops characters outside `allowed` and keeps existing behaviour when `allowed is None`.
- `show_cert_form()` returns `days`, seeds the field from the setting, and refuses to submit `0`.
Each verified by mutation — revert the logic, confirm the intended tests go red — as with the two preceding changes on this branch.
## Out of scope
- Changing the lifetime of an existing certificate (that is `easyrsa renew`, a different workflow).
- CA lifetime (`EASYRSA_CA_EXPIRE`) and CRL validity (`--days` for `gen-crl`).
- Warning when `CERT_DAYS` is shorter than `DAYS_AHEAD`, which would make every new cert appear in the expiring list immediately. Worth revisiting if it bites in practice.