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>
7.6 KiB
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
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:
_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,intvsstr, leading zeros.build_client_full()includes--days=90when given, omits it when"", and keeps--daysout of_base_cmd()(assertrevoke_issued/gen_crlcommands carry no--days).--daysparses and reachescreate/reissue; a bad value exits 1.CERT_DAYSsurvives an external.confoverride (extend the existing_OVERRIDABLE_SETTINGStest).InputFielddrops characters outsideallowedand keeps existing behaviour whenallowed is None.show_cert_form()returnsdays, seeds the field from the setting, and refuses to submit0.
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 (--daysforgen-crl). - Warning when
CERT_DAYSis shorter thanDAYS_AHEAD, which would make every new cert appear in the expiring list immediately. Worth revisiting if it bites in practice.